diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2093d0bb14..309f52fb21 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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: diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 92f24e0219..992f7c7263 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -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 diff --git a/.gitignore b/.gitignore index e5303fd75d..f527be1420 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ /.idea/studiobot.xml /.idea/other.xml /.idea/runConfigurations.xml +/.idea/ChatHistory_schema_v2.xml .DS_Store /build /captures diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 0000000000..91f95584dd --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.idea/kotlinNotebook.xml b/.idea/kotlinNotebook.xml new file mode 100644 index 0000000000..665e38dae3 --- /dev/null +++ b/.idea/kotlinNotebook.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index bb4493707f..bdfaa5e068 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,5 +1,11 @@ + + + + diff --git a/README.md b/README.md index 1b6b6ac5d3..27e3714122 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 8a22cb0f63..c1ae1376d4 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -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 -} - +} \ No newline at end of file diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/CashuBTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/CashuBTest.kt index 5fb552f09c..200c896d79 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/CashuBTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/CashuBTest.kt @@ -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>).loaded[0] + val parsed = (CashuParser().parse(cashuTokenA) as GenericLoadable.Loaded>).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>).loaded + val parsed = (CashuParser().parse(cashuTokenB1) as GenericLoadable.Loaded>).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>).loaded + val parsed = (CashuParser().parse(cashuTokenB2) as GenericLoadable.Loaded>).loaded assertEquals(cashuTokenB2, parsed[0].token) assertEquals("http://lbutlh5lfggq5r7xpiwhrajdl7sxpupgagazxl65w4c5cg72wtofasad.onion:3338", parsed[0].mint) diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/DMFileDecryptionTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/DMFileDecryptionTest.kt index 0c53bf2200..7e3e34a6ec 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/DMFileDecryptionTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/DMFileDecryptionTest.kt @@ -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() diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt index 90eca1f85f..ef98f7fa90 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt @@ -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)) diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/OkHttpOtsTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/OkHttpOtsTest.kt index d6503b45bc..d039911477 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/OkHttpOtsTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/OkHttpOtsTest.kt @@ -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 diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt index 215e089cdb..f1d881ce5b 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt @@ -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,10 +25,14 @@ import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter +import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket +import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal.ThreadFeedFilter import com.vitorpamplona.quartz.nip01Core.core.Event 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.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.verify import junit.framework.TestCase @@ -36,97 +40,121 @@ import junit.framework.TestCase.assertEquals import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class ThreadDualAxisChartAssemblerTest { - val db = - """ - [ - {"id":"741f5367a9415f4d6f19c0f57a1e4647c8ed8309b53b0da2d82fc4ebfba03b2c","pubkey":"d85c99afd244911e0aaf800cbea4221df557f06f8a4ff2cbe84b24e0b9e728fc","created_at":1684674845,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"Getting my head around this","sig":"069874040bac26a219777fc0f90b8f4df71e38c30e3e6a953d53222499d8e0c5a8f32c6b4204d14eb335bb654f01c5610372d9dc00062284b8e0f2bb98c7ed85"}, - {"id":"22323fc72b4c37f93ea21f6069684339ce5f63111161c81f2aa3de4a21bfe83b","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1683571810,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"test","sig":"f6d3bcf0f8e07d06720a2527f95910ee5a66aa889dd9261514921a155bbc3e9a3c7d721f5a2ec948cd795183f85dbe80f2ee2e36663f9d1de33fc03274ccb9d9"}, - {"id":"14525fcaae530a029f782fd361dd0cd66634c3e23020bd19e66fe11c1e254e32","pubkey":"afd563434a737334d69db899e4a32fe38d73a182bb6d1e91d83a2c4c4e04737c","created_at":1682968749,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"This is most amazing thing yet for nostr!","sig":"3dcbb058b8fa6e6f388f5b1a26d21b888dbc91bb831b0ed80ee30d9d2494fbbbb40dac9daf637b948c3afe1f32c2c662eca8d7acc0ba31f23b89edc8ade99631"}, - {"id":"98ae0d6d10e494ed0bf70feb577e8225c6a6732c7af3d29f88bfe1b87d4439e6","pubkey":"83fd07de9b763334cc9d46f2785c2558e6c2eabfe7d0c6ec214667cbaec50d47","created_at":1681995243,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"true if big","sig":"bb7d97e81207096d8615ebb0458c3023fa1b8f3aac463f3f0293ae94e2b90d043ad371067ce984a2ab2ba49cd7432a553f128b8d2d678238551538ad657bd75c"}, - {"id":"36e262e71b7e8bcae946f69885b8c3614e318e82864437342cf50e8b9ab7229d","pubkey":"b111d517452f9ef015e16d60ae623a6b66af63024eec941b0653bfee0dd667d4","created_at":1681979196,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"this is wonderful. great work.","sig":"205a46d867c22cbb09c7dcc4351330a95304c0d80540c85b9ea39f7f464ea60df1ef5006c1f93b862536f04d8a7855e4cc2a915d7a01437cd30f7f12c716b239"}, - {"id":"37725c2924ca267d66c2c27c2dae65550c07e7b883034cf1ea69671883430642","pubkey":"90b9bec74789688e515125596ab6350bfe646176ac75742275063922c5fea010","created_at":1681944950,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"Epic!","sig":"c461b97a3cdc5cf967e6f32c59c94b1b912c296feb1cbb7943c81731e558b10a3674982d6990567260d9fb033f5a3d3db8984b1eb362715d68ba46034a8b1b86"}, - {"id":"8ba54cfb6375270e8ae97a7e0992c1a0dbaa4cd46af8309d67a839e86789fde6","pubkey":"c48e29f04b482cc01ca1f9ef8c86ef8318c059e0e9353235162f080f26e14c11","created_at":1681944377,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"This is incredible... Bravo, Pablo, and shoutout to Gigi.","sig":"fb1a84d4859742b001b810c98a5e9301bc861fa2d65a711cb21c57f6e62c98f27e5b0c0c6432ef1111bc6cc52aa8cb0ae909d28af50c497d125950bfaeade475"}, - {"id":"53410bc6d47e87f3f18ecbc93c716b5a6ef8ee3805516b2ff4d155154a685b7c","pubkey":"fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52","created_at":1681945005,"kind":1,"tags":[["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["e","8ba54cfb6375270e8ae97a7e0992c1a0dbaa4cd46af8309d67a839e86789fde6"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["p","c48e29f04b482cc01ca1f9ef8c86ef8318c059e0e9353235162f080f26e14c11"]],"content":"Thank you, my friend 🤙 ","sig":"3d2fbcefdfac2183601076c8f9e518e05620cefe74cba233684cce931a69b51befa329c94734e434805bd9382ff3dad36e42d1955d4e47c822e973ba69b7377f"}, - {"id":"e383476cb1ce5accde11d4b1338424fa32c3724cf96e6214af8e5e852981728a","pubkey":"50de492cfe5472450df1a0176fdf6d915e97cb5d9f8d3eccef7d25ff0a8871de","created_at":1681932060,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"https://nostr.build/p/nb5753.png","sig":"9df6e3872f420ed5225aaa242f1c806b4739f17e265f02ca593a3c31c5fb5cb48705ee6bc0510f81f123527aa96ec4d07adf829362e2196bd7b833deded4c2b8"}, - {"id":"e2d8aaed336d3c0f73a9ca46a89fdb2da62a6d172936a91b0067a68797b3bcb8","pubkey":"50de492cfe5472450df1a0176fdf6d915e97cb5d9f8d3eccef7d25ff0a8871de","created_at":1681931869,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"Very Cool, I started playing around!","sig":"5980b2d0ef114d50c5f45b5d0c55bfc56b16a0a1807774ec2a86c4e352e17e3ec78877c73f2fedfbfd097a018df8e965107d6efd4ad9c0a0e1ecc89332d51cec"}, - {"id":"b92e4d6a5d0e8d1d2d2421044b84a4d11f2188261c55145d782b1b6bf0995009","pubkey":"fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52","created_at":1681932193,"kind":1,"tags":[["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f","","root"],["e","e2d8aaed336d3c0f73a9ca46a89fdb2da62a6d172936a91b0067a68797b3bcb8","","reply"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"]],"content":"love seeing it!!!","sig":"47850c205f1fa6d8836cda347c7d12c4307d35a110629eac48b4fff26600a14a5c5c8a7877b851db062a89e0157be112eae60ba0594525c1cc4d355906368dbb"}, - {"id":"da13e14cc8bcc243e0373dde14533d3829b8b621e214ca3c99c90f3dd9e11b8a","pubkey":"b488b594d84bb3e7c8421a4b91b19a006dc13d0c00da304a0263355d02195f04","created_at":1681931637,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"Great thoughts ","sig":"34c81430952508746fd5d5a4c4f7df0f5762aeb17d075a74f7b0daf387a7b8772edf4152731cff6650219e1aa9f8f63924410baa03091a698af0513cbf57e0af"}, - {"id":"b5234d90a1543ba60765c57ac3fc7140129a4ac28bbd013531ec9b85e256ea55","pubkey":"b488b594d84bb3e7c8421a4b91b19a006dc13d0c00da304a0263355d02195f04","created_at":1681931560,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"I like comments ","sig":"6915f4c2a08622339957495931eabc37f8d0c50ab1f3ec42327b67d15e50f50ff4bc302848b606e6d7c170696f8d744e6f896223f30ecb39a3adffe58020ff6f"}, - {"id":"fc4e4a1230b002e4ae08251a0b26107de7f518800188b7a504f5de62d8b07996","pubkey":"fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52","created_at":1681834615,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"This is exactly why I built this. I am enamored by this idea.","sig":"bdd1b3532efa3badeb3807fe74d31c25f17c84bbe39fe3c692d3d73efa39408089843ebb3c049840f2d525e6849f8254179a888bedc776ee7ad5e6997dc10c44"}, - {"id":"d4a0b4f08d98d82a04292654ec132723cc2cf3fa24ffb6c0833426cb9372f4d5","pubkey":"39a8b17475be0db44e313f9fd032ffde183c8abd6498e4932a873330d2cd4868","created_at":1680618462,"kind":1,"tags":[["e","44d3ecbb5752e96becc330d9b56352ea595d37b4c55edd8701fd24f18df5eee2"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["p","39a8b17475be0db44e313f9fd032ffde183c8abd6498e4932a873330d2cd4868"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"Unfortunately when boosting this it gets broken 🥹\n#[1]","sig":"c4a4197df4fec008f2f565170d09c46f0b9a7217ed90432cb53d1dddf70ad8520722b6e4f04ec9e52dd219ee01387c6191f449cd10a6c2488b1d2866424b7a8a"}, - {"id":"8cdc4676aca93bbafcfbe6784f9b2df54e8ca20fbe69ba55fda487736bfdb7f6","pubkey":"6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93","created_at":1680636068,"kind":1,"tags":[["e","44d3ecbb5752e96becc330d9b56352ea595d37b4c55edd8701fd24f18df5eee2"],["e","d4a0b4f08d98d82a04292654ec132723cc2cf3fa24ffb6c0833426cb9372f4d5"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["p","39a8b17475be0db44e313f9fd032ffde183c8abd6498e4932a873330d2cd4868"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"we early ","sig":"b8026e43c61b491014915caf79289968b87e52aa87a487ebecbf6cb2b3c07d460f2ab1a86da99b1c5410a0f5d14e77cf27f78c56bf1ab709f0d616ad2711de78"}, - {"id":"e15b386824fbfdcbf1b50b8860f03062cef534a3ea5339cc837536fb2a58465e","pubkey":"d3d2b986796c9e2067c58fc9017cace5adc1876e9b674719078d5c13d82e54a9","created_at":1690025613,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"]],"content":"test 3","sig":"1a34f9bb9eca624f4e0c3de2ad4ad7d9c915cd16e3f1e41066a69d3799a5bb19689a8e0d894e00649cae7f7d27beadbab2b04d4083f7f2ce68e5ee65af229f55"}, - {"id":"ba9a8a1a8afb0b53fb5d4fa3f5130fe557a8d8d56fac7af9ad3443531d2a2933","pubkey":"95181f816dc80a141e9158a2a398bec37f580f4309633321a1ca53757bf08794","created_at":1690373626,"kind":1,"tags":[["e","e15b386824fbfdcbf1b50b8860f03062cef534a3ea5339cc837536fb2a58465e"],["p","d3d2b986796c9e2067c58fc9017cace5adc1876e9b674719078d5c13d82e54a9"]],"content":"I see your tests. 1-3 🫡","sig":"cc8d3af65578cc2436878fb5dbf3ac27751b50f91b02dd944094e9f9f2965193e32fb20ce68cfe5555ecfb4b13fd06b9cdb673dc71e5db47199b8e6cde2d2e60"}, - {"id":"b0425132a3dd4142a0f78986166aaa28021cc8fb440c95c321a95afce3d5e056","pubkey":"d3d2b986796c9e2067c58fc9017cace5adc1876e9b674719078d5c13d82e54a9","created_at":1690025593,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"]],"content":"test 2","sig":"0226d5836991836ba105cd483def0baa4255bad19a8213ae483c0168efb4de6e493233ed1fdc83e038d5227e0254dc0b461e7a7fa8032a7eada56b9840e8762f"}, - {"id":"e9bb4e2d56bd2be2952570bd52b102c23444a62ada5b78ba086f086d9147651a","pubkey":"d3d2b986796c9e2067c58fc9017cace5adc1876e9b674719078d5c13d82e54a9","created_at":1690025573,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"]],"content":"test","sig":"ba66f1123e93a70645f3f01814378d23609cc2fda38f083410e0e06b037dc9fc45d139b690e2e892278f768c97a03e02e0a9cd0a8b385271db180e1e8838784d"}, - {"id":"4d7b21c462f3fbf27a1882dbaaec4e99e5a5d18a2c18d1f2d1f0736684ad157c","pubkey":"84142dede040f7e0852ba867cfa921986b7b7e92c4b7bc5c72fbd2a4577545e4","created_at":1688308094,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"]],"content":"testing","sig":"9f991643229822577c967d6d71831a6d98993dbbd3613b04abda0907a944b8eb20f6f8477bc8e922308e2e2135243ab78a31a93f285ee6215ac5536141a7d95d"}, - {"id":"9cdbced750e6b1e1274b7df47cb433f565414dd08c897167eba761eadee841cc","pubkey":"726a1e261cc6474674e8285e3951b3bb139be9a773d1acf49dc868db861a1c11","created_at":1687990252,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"]],"content":"Running zapthreads","sig":"1cd2209548f5a791807e4a8aeafbf3a2fb28cee013ad5f444f86dffd448668c05480d7714b892314f5fa50b7c65a23708362c1d5b0cb669c64cfb06ca8486ba9"}, - {"id":"5799aac7a9b06f3cae3d3791b79df29d14173515cfdbf34398aab73ed4a44121","pubkey":"726a1e261cc6474674e8285e3951b3bb139be9a773d1acf49dc868db861a1c11","created_at":1687996513,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"],["e","9cdbced750e6b1e1274b7df47cb433f565414dd08c897167eba761eadee841cc","","reply"]],"content":"Testing nested comments","sig":"335f9969d23e28fef45adfda63da236f1d4d72dfb5873fc78961d47ee948e9333632ce8a45df3c09fc431cf8d29d2fb99bea9c9a4b3502465b05a0328d563231"}, - {"id":"7a18dda355525d468b31bba4fa947cba98cc19048d4a3099d5e9ba045d878c26","pubkey":"45c41f21e1cf715fa6d9ca20b8e002a574db7bb49e96ee89834c66dac5446b7a","created_at":1680618145,"kind":1,"tags":[["e","1a1fe6c838400f3347f97a9adce08c10068f3bc35a279520c911b025c387b061",""],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93",""],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599",""],["title","Purple Text, Orange Highlights"],["summary","Using nostr as a base layer to create, consume, and monetize long-form content."],["image","https://dergigi.com/assets/images/reader.jpg"],["published_at","1680613802"]],"content":"good read. the highlighted part to be a shareable event 1 idea seems interesting, might explore it on flycat","sig":"ee913e997b907a03300616c36cffa12d2362932bd5e249cde39a44ec1250c7622703dddac167b093c3e3a28d41ebfeb9aed9d663d82bea9f8deaed4e342248dc"}, - {"id":"a3b3825af621727f9af3bd77392fe38c04d71658024916af7fe4c5867ef73eaa","pubkey":"619af6a60b3fe4c733aaca061c522cc9c7cf1d87ef4c908facc5ed936d3bdf23","created_at":1681941926,"kind":1,"tags":[["p","330fb1431ff9d8c250706bbcdc016d5495a3f744e047a408173e92ae7ee42dac"],["e","d7ef8220e3521779e2e0c3ff0afb9738f241e44ace8ed1d5237cab36330a69d0"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"Good or bad time?","sig":"5629e21769b018c7f050ad35e20c3926e5f9c415ebcd54656e874cff7948a9117de6fb32d18f993b7c96f480f3e4baffb079a3db7c126c74b0188006bba41768"}, - {"id":"c5ad64b1b72776a068c39f4549d089032432814a146849eba0650d1b329fb285","pubkey":"126df36019a793922eae86bfcfedc5240b68d67953892237e3eb500ab92140bf","created_at":1681911407,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","f2610be11393f884c020119316d3304bc06c48ab01f9cbcf5a5a8290031bf20e"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"Cool","sig":"62240add277f55e896ae24276fdae60f1580be9de4889e546fd2141b73e07b3e48f225a57914a5e0e5a2fd87570a1dfe1af4f869b355a7c455aa77f395c10bdb"}, - {"id":"6a58f8315af5badb1bdaeb5489417b94621a4d8e192ae2fedcca0c5dcf0c9cd4","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1681821926,"kind":1,"tags":[["p","73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc"],["e","81a6379301d7b517f96cc7d070b5b30fb43db97c5c814be3bc9f58d8fcbaf190"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"test 5","sig":"eeba84f251d8d79e0f16213b766aee8079a3a5018407c3f7d1e8161c762e6a735bb695ac54898e04d7abe245ec12f24d680927351d37a6f31557344adeebd3c2"}, - {"id":"6e9bb03c7c40d67fec0d0bb872548ec207ba0ac4533efa137d7bcaca9fb4b191","pubkey":"fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52","created_at":1681755803,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","9defafc0783f16b2ef9ac02d2e808a103d5d38f12905b7840420634af67d0821"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"test1","sig":"17c2489ee28dc32df68ebf87ced6f5f9af0e3f091b31298fece408acba43236bc6c9f50d756f61bcf46a204e043141535e0cd281d541146cbfac259814cd2cbe"}, - {"id":"e2ae784b239cac4bad38136e4bd758b87dd261b659ef460450064bf9073edcb3","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1681648013,"kind":1,"tags":[["p","73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc"],["e","81a6379301d7b517f96cc7d070b5b30fb43db97c5c814be3bc9f58d8fcbaf190"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"powerful quote 👀","sig":"eda9e5565000892dacab78469936d96aad7fb0dc0828630563b8ccb731f08e704eacfefff246b6bb668332e046f5650f2a9e4186c108f07cba07eefc775c6c58"}, - {"id":"45d4fc726f2cc5b524be862c14fdadc1a24b25b8c6c011eedf2d2909589263e7","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1681821952,"kind":1,"tags":[["p","73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc"],["e","e2ae784b239cac4bad38136e4bd758b87dd261b659ef460450064bf9073edcb3"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"test 7","sig":"e2c35766479d549700f4dbaeae385cfdacee1275ddf24777643ca7d7335c6d1baf7f89ac25d1dc90688bc49f8d2f5e4857d2234035291628a0c0ac760485bab0"}, - {"id":"7a4a2419824669f07081abe2132f8cc0027efbce066ccdf187c897bb7ffa5dc3","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1681821936,"kind":1,"tags":[["p","73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc"],["e","e2ae784b239cac4bad38136e4bd758b87dd261b659ef460450064bf9073edcb3"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"test 5","sig":"875339b3e666fb1013d6563552997dcdd02e87b1f290ad25b0d7dd085d2f87dd10a4e6c20dfe3fc109d1809733b93675505341fe149e4399c42371c743fafc73"}, - {"id":"674c62f84afdc045bc3623ea132d90afdfe4b64249807f65302231115af5406d","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1681648340,"kind":1,"tags":[["p","73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc"],["e","e2ae784b239cac4bad38136e4bd758b87dd261b659ef460450064bf9073edcb3"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"nested reply","sig":"f0f4fdd2d34e59255137c457b9d42fa9b5357e824f3154340de964616f6daf21e3b03491363b0060f97c5061445d7b2854eed328c5cced751b2b480e5ce8ce9a"}, - {"id":"d54761f672669ea4f4b7592f3b0a30ee28de340b0a7e46b91af94e66905171c9","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1681648345,"kind":1,"tags":[["p","73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc"],["e","674c62f84afdc045bc3623ea132d90afdfe4b64249807f65302231115af5406d"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"another one","sig":"dd732eb380542dd8068449d5617bb8b6c1dfe1495c9facbd46aad3ca9b084122479df30861b8e85e13e7b0c9bf9bce589b7fc062daa086c1ff8a0b1d1ffc7aa7"}, - {"id":"00813a18ac9084cd0948c27027a980e34039a3011f30279a8b52ad87da5a3031","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1681648351,"kind":1,"tags":[["p","73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc"],["e","d54761f672669ea4f4b7592f3b0a30ee28de340b0a7e46b91af94e66905171c9"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"yet another one","sig":"6332c8a441439487898fd5b423e42dde7d1d7aadfcf9b36defd78d76d1685ba099ac86ae00a1dac6ed5e8a166422d59bbb93bfccb2596dc1c5461efcfcf345b4"}, - {"id":"87a5bd25aa084cefb3357fc9c2a5b327254fab35fdd7b2d4bd0acddc63d0abe8","pubkey":"726a1e261cc6474674e8285e3951b3bb139be9a773d1acf49dc868db861a1c11","created_at":1687990701,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"],["e","00813a18ac9084cd0948c27027a980e34039a3011f30279a8b52ad87da5a3031","","reply"]],"content":"and another one more","sig":"6a385fb259ff40d9b6bd8bb1d5a2e20057ec1c5b5ed2c307fbc40e4a96d4c191b80b6ea3f3f5748c907d4b0e6e64a92210a0be2889af856ad084ec6806f3cbd8"}, - {"id":"512962dbada5fd5015fc727a107d5c3f569662de67eab8e5da5a8065012cf11e","pubkey":"7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194","created_at":1688194800,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"],["e","87a5bd25aa084cefb3357fc9c2a5b327254fab35fdd7b2d4bd0acddc63d0abe8","","reply"]],"content":"another one","sig":"b4bc4d8206a08de0a918043129b27c8863d17f60e4f58b4d2b535326625870d32640b04f247eb9eb5df1d62fe98c8e1b0341bdd119655553488300ec9d5b4036"}, - {"id":"ce6e32e3e17b6901d2cc70b60f3743e24f885bb6e9da6d88cff516079eac1883","pubkey":"726a1e261cc6474674e8285e3951b3bb139be9a773d1acf49dc868db861a1c11","created_at":1688234921,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"],["e","512962dbada5fd5015fc727a107d5c3f569662de67eab8e5da5a8065012cf11e","","reply"]],"content":"yet another one, testing 0.0.3","sig":"13bd41c4029c6a7ee41cb03d12e831e9e9b6e14d43a61c78a72657070b45385ba2ce98e8121049fbcbdb7b2dd777c36c7867ba70b5e6a4798a9b866910ae5b62"} - ] - """.trimIndent() + companion object { + val keyPair = KeyPair() + val account = + Account( + settings = AccountSettings(keyPair = keyPair), + signer = NostrSignerInternal(keyPair), + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + geolocationFlow = MutableStateFlow(LocationState.LocationResult.Loading), + cache = LocalCache, + client = + NostrClient( + OkHttpWebSocket.Builder { + OkHttpClient + .Builder() + .followRedirects(true) + .followSslRedirects(true) + .build() + }, + CoroutineScope(Dispatchers.IO + SupervisorJob()), + ), + ) - val header = - """ - { - "content": "Not too long ago, I tried to paint a picture of what\na [vision for a value-enabled web][vew]\ncould look like. Now, only a couple of months later,\nall this stuff is being built. On nostr, and on lightning. Orange and\npurple, a match made in heaven.\n\nIt goes without saying that I'm beyond delighted. What a time to be alive!\n\n## nostr\n\nHere's the thing that nostr got right, and it's the same thing that\nBitcoin got right: information is easy to spread and hard to stifle.[^fn-stifle]\nInformation can be copied quickly and perfectly, which is, I believe,\nthe underlying reason for its desire to be free.\n\n[^fn-stifle]: That's a [Satoshi quote][stifle], of course: \"Bitcoin's solution is to use a peer-to-peer network to check for double-spending. In a nutshell, the network works like a distributed timestamp server, stamping the first transaction to spend a coin. It takes advantage of the nature of information being easy to spread but hard to stifle.\"\n\n[stifle]: https://satoshi.nakamotoinstitute.org/posts/p2pfoundation/1/\n\nEasy to spread, hard to stifle. That's the base reality of the nature\nof information. As always, the smart thing is to work with nature, not\nagainst it.[^1] That's what's beautiful about the orange coin and\nthe purple ostrich: both manage to work with the peculiarities of\ninformation, not against them. Both realize that information can and should be\ncopied, as it can be perfectly read and easily spread, always. Both understand\nthat resistance to censorship comes from writing to many places, making the cost\nof deletion prohibitive.\n\n> Information does not just want to be free,\n> it longs to be free. Information expands to fill the available\n> storage space. Information is Rumor's younger, stronger cousin;\n> Information is fleeter of foot, has more eyes, knows more, and\n> understands less than Rumor.\n>\n> Eric Hughes, [A Cypherpunk's Manifesto][manifesto]\n\n[manifesto]: https://nakamotoinstitute.org/static/docs/cypherpunk-manifesto.txt\n\nNostr is quickly establishing itself as a base layer for information exchange,\none that is identity-native and value-enabled. It is distinctly different from\nsystems that came before it, just like Bitcoin is distinctly different from\nmonies that came before it.\n\nAs of today, the focus of nostr is mostly on short text notes, the so-called\n\"type 1\" events more commonly known as *tweets*.[^fn-kinds] However, as you should be aware\nby now, nostr is way more than just an alternative to twitter. It is a new\nparadigm. Change the note kind from `1` to `30023` and you don't have an\nalternative to Twitter, but a replacement for Medium, Substack, and all the\nother long-form platforms. I believe that special-purpose clients that focus on\ncertain content types will emerge over time, just like we have seen the\nemergence of special-purpose platforms in the Web 2.0 era. This time, however,\nthe network effects are cumulative, not separate. A new paradigm.\n\nLet me now turn to one such special-purpose client, a nostr-based reading app.\n\n[^fn-kinds]: Refer to the various NIPs to discover the multitude of [event kinds][kinds] defined by the protocol.\n\n[kinds]: https://github.com/nostr-protocol/nips#event-kinds\n[nip23]: https://github.com/nostr-protocol/nips/blob/master/23.md\n\n## Reading\n\nI'm constantly surprised that, even though most people do read a lot\nonline, very few people seem to have a reading workflow or reading\ntools.\n\nWhy that is is anyone's guess, but maybe the added value of such tools\nis not readily apparent. You can just read the stuff right there, on the\nad-ridden, dead-ugly site, right? Why should you sign up for another\nsite, use another app, or bind yourself to another closed platform?\n\nThat's a fair point, but the success of Medium and Substack shows that\nthere is an appetite for clean reading and writing, as well as providing\navenues for authors to get paid for their writing (and a willingness of\nreaders to support said authors, just because).\n\nThe problem is, of course, that all of these platforms are *platforms*,\nwhich is to say, walled gardens that imprison readers and writers alike.\nWorse than that: they are fiat platforms, which means that\npermissionless value-flows are not only absent from their DNA, they are\noutright impossible.[^2]\n\nNostr fixes this.\n\n![Nostriches like to read, or so I've heard](https://dergigi.com/assets/images/bitcoin/2023-04-04-purple-text-orange-highlights/nostrich-reading-a-newspaper.jpg)\n\nThe beauty of nostr is that it is not a platform. It's a protocol,\nwhich means that you don't have to sign up for it---you can create an\nidentity yourself. You don't have to ask for permission; you just *do*,\nwithout having to rely on the benevolence of whatever dictator is in\ncharge of the platform right now.\n\nNostr is *not* a platform, and yet, powerful tools and services can be\nbuilt and monetized on top of it. This is good for users, good for\nservice providers, and good for the network(s) at large. Win-win-win.\n\nSo what am I talking about, exactly? How can nostr improve everyone's\nreading (and writing) experience?\n\nAllow me to paint a (rough) picture of what I have in mind. Nostr\nalready supports private and public bookmarks, so let's start from\nthere.\n\nImagine a special-purpose client that scans all your bookmarks for long-form\ncontent.[^fn-urls] Everything that you marked to be read later is shown in an orderly\nfashion, which is to say searchable, sortable, filterable, and displayed without\ndistractions. Voilà, you have yourself a reading app. That's, in essence, how\nPocket, Readwise, and other reading apps work. But all these apps are walled\ngardens without much interoperability and without direct monetization.\n\n[^fn-urls]: In the nostr world long-form content is simply markdown as defined in [NIP-23][nip23], but it could also be a link to an article or PDF, which in turn could get [converted into markdown][readability] and posted as an event to a special relay.\n\n[readability]: https://github.com/mozilla/readability\n\nBitcoin fixes the direct monetization part.[^fn-v4v] Nostr fixes the interoperability part.\n\n[^fn-v4v]: ...because Bitcoin makes [V4V][busking] practical. (Paywalls are not the way.)\n\nAlright, we got ourselves a boring reading app. Great. Now, imagine that\nusers are able to highlight passages. These highlights, just like\nbookmarks now, could be private or public. When shared publicly,\nsomething interesting emerges: an overlay on existing content, a lens on\nthe written Web. In other words: *swarm highlights*.\n\nImagine a visual overlay of all public highlights, automatically shining\na light on what the swarm of readers found most useful, insightful,\nfunny, etc.\n\n![Swarm Highlights](https://dergigi.com/assets/images/bitcoin/2023-04-04-purple-text-orange-highlights/highlights.png)\n\nFurther, imagine the possibility of sharing these highlights as a \"type 1\" event\nwith one click, automatically tagging the highlighter(s)---as well as the\nauthor, of course---so that eventual sat-flows can be split and forwarded\nautomatically.\n\n![Automated value splits](https://dergigi.com/assets/images/bitcoin/2023-04-04-purple-text-orange-highlights/sat-flows.png)\n\nVoilà, you have a system that allows for value to flow back to those who\nprovide it, be it authors, editors, curators, or readers that willingly\nslog through the information jungle to share and highlight the best\nstuff (which is a form of curation, of course).\n\nZaps make nostr a defacto address book[^fn-pp] of payment information, which is\nto say lightning addresses, as of now. Thanks to [nostr wallet connect][nwc] (among\nother developments), sending sats ~~will soon be~~ is already as\nfrictionless as leaving a like.\n\n[^fn-pp]: The Yellow Pages are dead, long live [The Purple Pages](http://purplepag.es/)!\n\nValue-for-value and participatory payment flows are something that\ntraditional reading apps desperately lack, be it Pocket, Instapaper,\nReadwise, or the simple reading mode that is part of every browser.\n\nA neat side-effect of a more structured way to share passages of text is\nthat it enables semi-structured discussions around said\npassages---which could be another useful overlay inside\nspecial-purpose clients, providing context and further insights.[^5]\n\nFurther, imagine the option of seamlessly switching from text-on-screen\nto text-to-speech, allowing the user to stream sats if desired, as\nPodcasting 2.0 clients already do.[^3]\n\nImagine user-built curations of the best articles of the week, bundled\nneatly for your reading pleasure, incentivized by a small value split\nthat allows the curator to participate in the flow of sats.\n\nYou get the idea.\n\nI'm sure that the various implementation details will be hashed out,\nbut as I see it, 90% of the stuff is already there. Maybe we'll need\nanother NIP or two, but I don't see a reason why this can't be\nbuilt---and, more importantly: I don't see a reason why it wouldn't\nbe sustainable for everyone involved.\n\nMost puzzle pieces are already there, and the rest of them can probably\nbe implemented by custom event types. From the point of view of nostr,\nmost everything is an event: bookmarks are events, highlights are\nevents, marking something as read is an event, and sharing an excerpt or\na highlight is an event. Public actions are out in the open, private\nactions are encrypted, the data is not in a silo, and everyone wins.\nEspecially the users, those who are at the edge of the network and\nusually lose out on the value generated.\n\nIn this case, the reading case, the users are mostly \"consumers\" of\ncontent. What changes from the producing perspective, the perspective of\nthe writer?\n\n## Writing\n\nBack to the one thing that nostr got right: information is easy to\nspread but hard to stifle. In addition to that, digital information can\nbe copied perfectly, which is why it shouldn't matter where stuff is\npublished in the first place.\n\nAllow me to repeat this point in all caps, for emphasis: **IT SHOULD NOT\nMATTER WHERE INFORMATION IS PUBLISHED**, and, maybe even more\nimportantly, it shouldn't matter if it is published in a hundred\ndifferent places at once.[^fn-torrents]\n\nWhat matters is trust and accuracy, which is to say, digital signatures\nand reputation. To translate this to nostr speak: because every event is\nsigned by default, as long as you trust the person behind the signature,\nit doesn't matter from which relay the information is fetched.\n\nThis is already true (or mostly true) on the regular web. Whether you\nread the internet archive version of an article or the version that is\npublished by an online magazine, the version on the author's website,\nor the version read by some guy that has read more about Bitcoin than\nanyone else you know[^fn-guy]---it's all the same, essentially. What matters\nis the information itself.\n\n[^fn-guy]: There is only one such guy, as we all know, and it's this Guy: nostr:npub1h8nk2346qezka5cpm8jjh3yl5j88pf4ly2ptu7s6uu55wcfqy0wq36rpev\n\nPractically speaking, the source of truth in a hypernostrized world is---you\nguessed it---an event. An event signed by the author, which allows for\nthe information to be wrapped in a tamper-proof manner, which in turn\nallows the information to spread far and wide---without it being\nhosted in one place.\n\nThe first clients that focus on long-form content already exist, and I expect\nmore clients to pop up over time.[^4] As mentioned before, one could easily\nimagine [prism-like value splits][prism] seamlessly integrated into these\nclients, splitting zaps automatically to compensate writers, editors,\nproofreaders, and illustrators in a V4V fashion. Further, one could imagine\nvarious compute-intensive services built into these special-purpose clients,\nsuch as GPT Ghostwriters, or writing aids such as Grammarly and the like. All\nthese services could be seamlessly paid for in sats, without the requirement of\nany sign-ups or the gathering of any user data. That's the beauty of [money\nproper][rediscovery].\n\n![A clean and simple reading and writing interface](https://dergigi.com/assets/images/bitcoin/2023-04-04-purple-text-orange-highlights/nostr-reader-and-writer.png)\n\nPlagiarism is one issue that needs to be dealt with, of course. Humans\nare greedy, and some humans are assholes. Neither bitcoin nor nostr\nfixes this. However, while plagiarism detection is not necessarily\ntrivial, it is also not impossible, especially if most texts are\npublished on nostr first. Nostr-based publishing tools allow for\nOpenTimestamp attestations thanks\nto [NIP-03](https://github.com/nostr-protocol/nips/blob/master/03.md),\nwhich in turn allows for plagiarism detection based on \"first seen\"\nlookups.\n\nThat's just one way to deal with the problem, of course. In any case,\nI'm confident that we'll figure it out.\n\n## Value\n\nI believe that in the open ~~attention~~ information economy we find\nourselves in, value will mostly derive from effective curation,\ndissemination, and transmission of information, *not* the exclusive\nownership of it.\n\nAlthough it is still early days,\nthe [statistics](https://stats.podcastindex.org/v4v) around Podcasting\n2.0 and [nostr zaps](https://zaplife.lol/) clearly show that (a) people\nare willing to monetarily reward content they care about, and (b) the\nwillingness to send sats *increases* as friction *decreases*.\n\nThe ingenious thing about boostagrams and zaps is that they are direct\nand visible, which is to say, public and interactive. They are neither\nregular transactions nor simple donations---they are something else\nentirely. An unforgable value signal, a special form of gratitude and\nappreciation.\n\nContrast that with a link to Paypal or Patreon: impersonal, slow,\nindirect, and friction-laden. It's the opposite of a super-charged\ninteraction.\n\nWhile today's information jungle increasingly presents itself in the\nform of (short) videos and (long-form) audio, I believe that we will see\na renaissance of the written word, especially if we manage to move away\nfrom an economy built around attention, towards an economy built upon\nvalue and insight.\n\nThe orange future now has a purple hue, and I believe that it will be as\nbright as ever. We just have a lot of building to do.\n\n---\n\n## Further Reading\n\n- [A Vision for a Value-Enabled Web][vew]\n- [The Freedom of Value][busking]\n- [The Rediscovery of Money][prism]\n- [Lightning Prisms][rediscovery]\n\n[vew]: https://dergigi.com/vew\n[prism]: https://dergigi.com/prism\n[rediscovery]: https://dergigi.com/rediscovery\n[busking]: https://dergigi.com/busking\n\n## NIPs and Resources\n\n- [Nostr Resources][nr]\n- [value4value.info](https://value4value.info/)\n- [nips.be](https://nips.be/)\n- [NIP-23: Long-form content](https://github.com/nostr-protocol/nips/blob/master/23.md)\n- [NIP-57: Event-specific zap markers](https://github.com/nostr-protocol/nips/blob/master/57.md)\n- [NIP-47: Nostr Wallet Connect](https://github.com/getAlby/nips/blob/master/47.md)\n- [NIP-03: OpenTimestamps attestations for events](https://github.com/nostr-protocol/nips/blob/master/03.md)\n\nOriginally published on [dergigi.com](https://dergigi.com/reader)\n\n---\n\n[^1]: Paywalls work against this nature, which is why I consider them misguided at best and incredibly retarded at worst.\n\n[^2]: Fiat doesn't work for the [value-enabled web][vew], as fiat rails can never be open and permissionless. Digital fiat is never money. It is---and always will be---[credit][rediscovery].\n\n[^3]: Whether the recipient is a text-to-speech service provider or a human narrator doesn't even matter too much, sats will flow just the same.\n\n[^4]: [BlogStack](https://blogstack.io/) and [Habla](https://habla.news/) being two of them.\n\n[^5]: Use a URI as the discussion base (instead of a highlight), and you got yourself a [Disqus](https://disqus.com/) in purple feathers!\n\n[^fn-torrents]: That's what torrents got right, and [ipfs] for that matter.\n\n[nr]: https://nostr-resources.com\n[nwc]: https://nwc.getalby.com/\n[ipfs]: https://fiatjaf.com/d5031e5b.html\n", - "created_at": 1680614039, - "id": "9517aa60334990596fdea4493c1bde429c55e9cefb84d8d88a6758c4e3ebcabc", - "kind": 30023, - "pubkey": "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93", - "sig": "c8c8c7ac8c288f6a6e1090e0e5f7eed85daaaba03eeab0a4936535f95ce066c08723bedcfb80a6af4941a9fafb9fe351950b9376d13ff0e81030cd4391840f04", - "tags": [ - ["d","1680612926599"], - ["title","Purple Text, Orange Highlights"], - ["summary","Using nostr as a base layer to create, consume, and monetize long-form content."], - ["published_at","1680613802"], - ["t","nostr"], - ["t","reading"], - ["t","writing"], - ["t","readability"], - ["t","highlights"], - ["t","pocket"], - ["t","instapaper"], - ["t","readwise"], - ["t","disqus"], - ["t","v4v"], - ["t","value4value"], - ["image","https://dergigi.com/assets/images/reader.jpg"], - ["p","b9e76546ba06456ed301d9e52bc49fa48e70a6bf2282be7a1ae72947612023dc"] - ] - } - """.trimIndent() + val db = + """ + [ + {"id":"741f5367a9415f4d6f19c0f57a1e4647c8ed8309b53b0da2d82fc4ebfba03b2c","pubkey":"d85c99afd244911e0aaf800cbea4221df557f06f8a4ff2cbe84b24e0b9e728fc","created_at":1684674845,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"Getting my head around this","sig":"069874040bac26a219777fc0f90b8f4df71e38c30e3e6a953d53222499d8e0c5a8f32c6b4204d14eb335bb654f01c5610372d9dc00062284b8e0f2bb98c7ed85"}, + {"id":"22323fc72b4c37f93ea21f6069684339ce5f63111161c81f2aa3de4a21bfe83b","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1683571810,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"test","sig":"f6d3bcf0f8e07d06720a2527f95910ee5a66aa889dd9261514921a155bbc3e9a3c7d721f5a2ec948cd795183f85dbe80f2ee2e36663f9d1de33fc03274ccb9d9"}, + {"id":"14525fcaae530a029f782fd361dd0cd66634c3e23020bd19e66fe11c1e254e32","pubkey":"afd563434a737334d69db899e4a32fe38d73a182bb6d1e91d83a2c4c4e04737c","created_at":1682968749,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"This is most amazing thing yet for nostr!","sig":"3dcbb058b8fa6e6f388f5b1a26d21b888dbc91bb831b0ed80ee30d9d2494fbbbb40dac9daf637b948c3afe1f32c2c662eca8d7acc0ba31f23b89edc8ade99631"}, + {"id":"98ae0d6d10e494ed0bf70feb577e8225c6a6732c7af3d29f88bfe1b87d4439e6","pubkey":"83fd07de9b763334cc9d46f2785c2558e6c2eabfe7d0c6ec214667cbaec50d47","created_at":1681995243,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"true if big","sig":"bb7d97e81207096d8615ebb0458c3023fa1b8f3aac463f3f0293ae94e2b90d043ad371067ce984a2ab2ba49cd7432a553f128b8d2d678238551538ad657bd75c"}, + {"id":"36e262e71b7e8bcae946f69885b8c3614e318e82864437342cf50e8b9ab7229d","pubkey":"b111d517452f9ef015e16d60ae623a6b66af63024eec941b0653bfee0dd667d4","created_at":1681979196,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"this is wonderful. great work.","sig":"205a46d867c22cbb09c7dcc4351330a95304c0d80540c85b9ea39f7f464ea60df1ef5006c1f93b862536f04d8a7855e4cc2a915d7a01437cd30f7f12c716b239"}, + {"id":"37725c2924ca267d66c2c27c2dae65550c07e7b883034cf1ea69671883430642","pubkey":"90b9bec74789688e515125596ab6350bfe646176ac75742275063922c5fea010","created_at":1681944950,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"Epic!","sig":"c461b97a3cdc5cf967e6f32c59c94b1b912c296feb1cbb7943c81731e558b10a3674982d6990567260d9fb033f5a3d3db8984b1eb362715d68ba46034a8b1b86"}, + {"id":"8ba54cfb6375270e8ae97a7e0992c1a0dbaa4cd46af8309d67a839e86789fde6","pubkey":"c48e29f04b482cc01ca1f9ef8c86ef8318c059e0e9353235162f080f26e14c11","created_at":1681944377,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"This is incredible... Bravo, Pablo, and shoutout to Gigi.","sig":"fb1a84d4859742b001b810c98a5e9301bc861fa2d65a711cb21c57f6e62c98f27e5b0c0c6432ef1111bc6cc52aa8cb0ae909d28af50c497d125950bfaeade475"}, + {"id":"53410bc6d47e87f3f18ecbc93c716b5a6ef8ee3805516b2ff4d155154a685b7c","pubkey":"fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52","created_at":1681945005,"kind":1,"tags":[["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["e","8ba54cfb6375270e8ae97a7e0992c1a0dbaa4cd46af8309d67a839e86789fde6"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["p","c48e29f04b482cc01ca1f9ef8c86ef8318c059e0e9353235162f080f26e14c11"]],"content":"Thank you, my friend 🤙 ","sig":"3d2fbcefdfac2183601076c8f9e518e05620cefe74cba233684cce931a69b51befa329c94734e434805bd9382ff3dad36e42d1955d4e47c822e973ba69b7377f"}, + {"id":"e383476cb1ce5accde11d4b1338424fa32c3724cf96e6214af8e5e852981728a","pubkey":"50de492cfe5472450df1a0176fdf6d915e97cb5d9f8d3eccef7d25ff0a8871de","created_at":1681932060,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"https://nostr.build/p/nb5753.png","sig":"9df6e3872f420ed5225aaa242f1c806b4739f17e265f02ca593a3c31c5fb5cb48705ee6bc0510f81f123527aa96ec4d07adf829362e2196bd7b833deded4c2b8"}, + {"id":"e2d8aaed336d3c0f73a9ca46a89fdb2da62a6d172936a91b0067a68797b3bcb8","pubkey":"50de492cfe5472450df1a0176fdf6d915e97cb5d9f8d3eccef7d25ff0a8871de","created_at":1681931869,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"Very Cool, I started playing around!","sig":"5980b2d0ef114d50c5f45b5d0c55bfc56b16a0a1807774ec2a86c4e352e17e3ec78877c73f2fedfbfd097a018df8e965107d6efd4ad9c0a0e1ecc89332d51cec"}, + {"id":"b92e4d6a5d0e8d1d2d2421044b84a4d11f2188261c55145d782b1b6bf0995009","pubkey":"fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52","created_at":1681932193,"kind":1,"tags":[["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f","","root"],["e","e2d8aaed336d3c0f73a9ca46a89fdb2da62a6d172936a91b0067a68797b3bcb8","","reply"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"]],"content":"love seeing it!!!","sig":"47850c205f1fa6d8836cda347c7d12c4307d35a110629eac48b4fff26600a14a5c5c8a7877b851db062a89e0157be112eae60ba0594525c1cc4d355906368dbb"}, + {"id":"da13e14cc8bcc243e0373dde14533d3829b8b621e214ca3c99c90f3dd9e11b8a","pubkey":"b488b594d84bb3e7c8421a4b91b19a006dc13d0c00da304a0263355d02195f04","created_at":1681931637,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"Great thoughts ","sig":"34c81430952508746fd5d5a4c4f7df0f5762aeb17d075a74f7b0daf387a7b8772edf4152731cff6650219e1aa9f8f63924410baa03091a698af0513cbf57e0af"}, + {"id":"b5234d90a1543ba60765c57ac3fc7140129a4ac28bbd013531ec9b85e256ea55","pubkey":"b488b594d84bb3e7c8421a4b91b19a006dc13d0c00da304a0263355d02195f04","created_at":1681931560,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"I like comments ","sig":"6915f4c2a08622339957495931eabc37f8d0c50ab1f3ec42327b67d15e50f50ff4bc302848b606e6d7c170696f8d744e6f896223f30ecb39a3adffe58020ff6f"}, + {"id":"fc4e4a1230b002e4ae08251a0b26107de7f518800188b7a504f5de62d8b07996","pubkey":"fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52","created_at":1681834615,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"This is exactly why I built this. I am enamored by this idea.","sig":"bdd1b3532efa3badeb3807fe74d31c25f17c84bbe39fe3c692d3d73efa39408089843ebb3c049840f2d525e6849f8254179a888bedc776ee7ad5e6997dc10c44"}, + {"id":"d4a0b4f08d98d82a04292654ec132723cc2cf3fa24ffb6c0833426cb9372f4d5","pubkey":"39a8b17475be0db44e313f9fd032ffde183c8abd6498e4932a873330d2cd4868","created_at":1680618462,"kind":1,"tags":[["e","44d3ecbb5752e96becc330d9b56352ea595d37b4c55edd8701fd24f18df5eee2"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["p","39a8b17475be0db44e313f9fd032ffde183c8abd6498e4932a873330d2cd4868"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"Unfortunately when boosting this it gets broken 🥹\n#[1]","sig":"c4a4197df4fec008f2f565170d09c46f0b9a7217ed90432cb53d1dddf70ad8520722b6e4f04ec9e52dd219ee01387c6191f449cd10a6c2488b1d2866424b7a8a"}, + {"id":"8cdc4676aca93bbafcfbe6784f9b2df54e8ca20fbe69ba55fda487736bfdb7f6","pubkey":"6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93","created_at":1680636068,"kind":1,"tags":[["e","44d3ecbb5752e96becc330d9b56352ea595d37b4c55edd8701fd24f18df5eee2"],["e","d4a0b4f08d98d82a04292654ec132723cc2cf3fa24ffb6c0833426cb9372f4d5"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["p","39a8b17475be0db44e313f9fd032ffde183c8abd6498e4932a873330d2cd4868"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"we early ","sig":"b8026e43c61b491014915caf79289968b87e52aa87a487ebecbf6cb2b3c07d460f2ab1a86da99b1c5410a0f5d14e77cf27f78c56bf1ab709f0d616ad2711de78"}, + {"id":"e15b386824fbfdcbf1b50b8860f03062cef534a3ea5339cc837536fb2a58465e","pubkey":"d3d2b986796c9e2067c58fc9017cace5adc1876e9b674719078d5c13d82e54a9","created_at":1690025613,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"]],"content":"test 3","sig":"1a34f9bb9eca624f4e0c3de2ad4ad7d9c915cd16e3f1e41066a69d3799a5bb19689a8e0d894e00649cae7f7d27beadbab2b04d4083f7f2ce68e5ee65af229f55"}, + {"id":"ba9a8a1a8afb0b53fb5d4fa3f5130fe557a8d8d56fac7af9ad3443531d2a2933","pubkey":"95181f816dc80a141e9158a2a398bec37f580f4309633321a1ca53757bf08794","created_at":1690373626,"kind":1,"tags":[["e","e15b386824fbfdcbf1b50b8860f03062cef534a3ea5339cc837536fb2a58465e"],["p","d3d2b986796c9e2067c58fc9017cace5adc1876e9b674719078d5c13d82e54a9"]],"content":"I see your tests. 1-3 🫡","sig":"cc8d3af65578cc2436878fb5dbf3ac27751b50f91b02dd944094e9f9f2965193e32fb20ce68cfe5555ecfb4b13fd06b9cdb673dc71e5db47199b8e6cde2d2e60"}, + {"id":"b0425132a3dd4142a0f78986166aaa28021cc8fb440c95c321a95afce3d5e056","pubkey":"d3d2b986796c9e2067c58fc9017cace5adc1876e9b674719078d5c13d82e54a9","created_at":1690025593,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"]],"content":"test 2","sig":"0226d5836991836ba105cd483def0baa4255bad19a8213ae483c0168efb4de6e493233ed1fdc83e038d5227e0254dc0b461e7a7fa8032a7eada56b9840e8762f"}, + {"id":"e9bb4e2d56bd2be2952570bd52b102c23444a62ada5b78ba086f086d9147651a","pubkey":"d3d2b986796c9e2067c58fc9017cace5adc1876e9b674719078d5c13d82e54a9","created_at":1690025573,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"]],"content":"test","sig":"ba66f1123e93a70645f3f01814378d23609cc2fda38f083410e0e06b037dc9fc45d139b690e2e892278f768c97a03e02e0a9cd0a8b385271db180e1e8838784d"}, + {"id":"4d7b21c462f3fbf27a1882dbaaec4e99e5a5d18a2c18d1f2d1f0736684ad157c","pubkey":"84142dede040f7e0852ba867cfa921986b7b7e92c4b7bc5c72fbd2a4577545e4","created_at":1688308094,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"]],"content":"testing","sig":"9f991643229822577c967d6d71831a6d98993dbbd3613b04abda0907a944b8eb20f6f8477bc8e922308e2e2135243ab78a31a93f285ee6215ac5536141a7d95d"}, + {"id":"9cdbced750e6b1e1274b7df47cb433f565414dd08c897167eba761eadee841cc","pubkey":"726a1e261cc6474674e8285e3951b3bb139be9a773d1acf49dc868db861a1c11","created_at":1687990252,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"]],"content":"Running zapthreads","sig":"1cd2209548f5a791807e4a8aeafbf3a2fb28cee013ad5f444f86dffd448668c05480d7714b892314f5fa50b7c65a23708362c1d5b0cb669c64cfb06ca8486ba9"}, + {"id":"5799aac7a9b06f3cae3d3791b79df29d14173515cfdbf34398aab73ed4a44121","pubkey":"726a1e261cc6474674e8285e3951b3bb139be9a773d1acf49dc868db861a1c11","created_at":1687996513,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"],["e","9cdbced750e6b1e1274b7df47cb433f565414dd08c897167eba761eadee841cc","","reply"]],"content":"Testing nested comments","sig":"335f9969d23e28fef45adfda63da236f1d4d72dfb5873fc78961d47ee948e9333632ce8a45df3c09fc431cf8d29d2fb99bea9c9a4b3502465b05a0328d563231"}, + {"id":"7a18dda355525d468b31bba4fa947cba98cc19048d4a3099d5e9ba045d878c26","pubkey":"45c41f21e1cf715fa6d9ca20b8e002a574db7bb49e96ee89834c66dac5446b7a","created_at":1680618145,"kind":1,"tags":[["e","1a1fe6c838400f3347f97a9adce08c10068f3bc35a279520c911b025c387b061",""],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93",""],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599",""],["title","Purple Text, Orange Highlights"],["summary","Using nostr as a base layer to create, consume, and monetize long-form content."],["image","https://dergigi.com/assets/images/reader.jpg"],["published_at","1680613802"]],"content":"good read. the highlighted part to be a shareable event 1 idea seems interesting, might explore it on flycat","sig":"ee913e997b907a03300616c36cffa12d2362932bd5e249cde39a44ec1250c7622703dddac167b093c3e3a28d41ebfeb9aed9d663d82bea9f8deaed4e342248dc"}, + {"id":"a3b3825af621727f9af3bd77392fe38c04d71658024916af7fe4c5867ef73eaa","pubkey":"619af6a60b3fe4c733aaca061c522cc9c7cf1d87ef4c908facc5ed936d3bdf23","created_at":1681941926,"kind":1,"tags":[["p","330fb1431ff9d8c250706bbcdc016d5495a3f744e047a408173e92ae7ee42dac"],["e","d7ef8220e3521779e2e0c3ff0afb9738f241e44ace8ed1d5237cab36330a69d0"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"Good or bad time?","sig":"5629e21769b018c7f050ad35e20c3926e5f9c415ebcd54656e874cff7948a9117de6fb32d18f993b7c96f480f3e4baffb079a3db7c126c74b0188006bba41768"}, + {"id":"c5ad64b1b72776a068c39f4549d089032432814a146849eba0650d1b329fb285","pubkey":"126df36019a793922eae86bfcfedc5240b68d67953892237e3eb500ab92140bf","created_at":1681911407,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","f2610be11393f884c020119316d3304bc06c48ab01f9cbcf5a5a8290031bf20e"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"Cool","sig":"62240add277f55e896ae24276fdae60f1580be9de4889e546fd2141b73e07b3e48f225a57914a5e0e5a2fd87570a1dfe1af4f869b355a7c455aa77f395c10bdb"}, + {"id":"6a58f8315af5badb1bdaeb5489417b94621a4d8e192ae2fedcca0c5dcf0c9cd4","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1681821926,"kind":1,"tags":[["p","73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc"],["e","81a6379301d7b517f96cc7d070b5b30fb43db97c5c814be3bc9f58d8fcbaf190"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"test 5","sig":"eeba84f251d8d79e0f16213b766aee8079a3a5018407c3f7d1e8161c762e6a735bb695ac54898e04d7abe245ec12f24d680927351d37a6f31557344adeebd3c2"}, + {"id":"6e9bb03c7c40d67fec0d0bb872548ec207ba0ac4533efa137d7bcaca9fb4b191","pubkey":"fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52","created_at":1681755803,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","9defafc0783f16b2ef9ac02d2e808a103d5d38f12905b7840420634af67d0821"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"test1","sig":"17c2489ee28dc32df68ebf87ced6f5f9af0e3f091b31298fece408acba43236bc6c9f50d756f61bcf46a204e043141535e0cd281d541146cbfac259814cd2cbe"}, + {"id":"e2ae784b239cac4bad38136e4bd758b87dd261b659ef460450064bf9073edcb3","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1681648013,"kind":1,"tags":[["p","73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc"],["e","81a6379301d7b517f96cc7d070b5b30fb43db97c5c814be3bc9f58d8fcbaf190"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"powerful quote 👀","sig":"eda9e5565000892dacab78469936d96aad7fb0dc0828630563b8ccb731f08e704eacfefff246b6bb668332e046f5650f2a9e4186c108f07cba07eefc775c6c58"}, + {"id":"45d4fc726f2cc5b524be862c14fdadc1a24b25b8c6c011eedf2d2909589263e7","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1681821952,"kind":1,"tags":[["p","73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc"],["e","e2ae784b239cac4bad38136e4bd758b87dd261b659ef460450064bf9073edcb3"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"test 7","sig":"e2c35766479d549700f4dbaeae385cfdacee1275ddf24777643ca7d7335c6d1baf7f89ac25d1dc90688bc49f8d2f5e4857d2234035291628a0c0ac760485bab0"}, + {"id":"7a4a2419824669f07081abe2132f8cc0027efbce066ccdf187c897bb7ffa5dc3","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1681821936,"kind":1,"tags":[["p","73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc"],["e","e2ae784b239cac4bad38136e4bd758b87dd261b659ef460450064bf9073edcb3"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"test 5","sig":"875339b3e666fb1013d6563552997dcdd02e87b1f290ad25b0d7dd085d2f87dd10a4e6c20dfe3fc109d1809733b93675505341fe149e4399c42371c743fafc73"}, + {"id":"674c62f84afdc045bc3623ea132d90afdfe4b64249807f65302231115af5406d","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1681648340,"kind":1,"tags":[["p","73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc"],["e","e2ae784b239cac4bad38136e4bd758b87dd261b659ef460450064bf9073edcb3"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"nested reply","sig":"f0f4fdd2d34e59255137c457b9d42fa9b5357e824f3154340de964616f6daf21e3b03491363b0060f97c5061445d7b2854eed328c5cced751b2b480e5ce8ce9a"}, + {"id":"d54761f672669ea4f4b7592f3b0a30ee28de340b0a7e46b91af94e66905171c9","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1681648345,"kind":1,"tags":[["p","73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc"],["e","674c62f84afdc045bc3623ea132d90afdfe4b64249807f65302231115af5406d"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"another one","sig":"dd732eb380542dd8068449d5617bb8b6c1dfe1495c9facbd46aad3ca9b084122479df30861b8e85e13e7b0c9bf9bce589b7fc062daa086c1ff8a0b1d1ffc7aa7"}, + {"id":"00813a18ac9084cd0948c27027a980e34039a3011f30279a8b52ad87da5a3031","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1681648351,"kind":1,"tags":[["p","73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc"],["e","d54761f672669ea4f4b7592f3b0a30ee28de340b0a7e46b91af94e66905171c9"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"yet another one","sig":"6332c8a441439487898fd5b423e42dde7d1d7aadfcf9b36defd78d76d1685ba099ac86ae00a1dac6ed5e8a166422d59bbb93bfccb2596dc1c5461efcfcf345b4"}, + {"id":"87a5bd25aa084cefb3357fc9c2a5b327254fab35fdd7b2d4bd0acddc63d0abe8","pubkey":"726a1e261cc6474674e8285e3951b3bb139be9a773d1acf49dc868db861a1c11","created_at":1687990701,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"],["e","00813a18ac9084cd0948c27027a980e34039a3011f30279a8b52ad87da5a3031","","reply"]],"content":"and another one more","sig":"6a385fb259ff40d9b6bd8bb1d5a2e20057ec1c5b5ed2c307fbc40e4a96d4c191b80b6ea3f3f5748c907d4b0e6e64a92210a0be2889af856ad084ec6806f3cbd8"}, + {"id":"512962dbada5fd5015fc727a107d5c3f569662de67eab8e5da5a8065012cf11e","pubkey":"7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194","created_at":1688194800,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"],["e","87a5bd25aa084cefb3357fc9c2a5b327254fab35fdd7b2d4bd0acddc63d0abe8","","reply"]],"content":"another one","sig":"b4bc4d8206a08de0a918043129b27c8863d17f60e4f58b4d2b535326625870d32640b04f247eb9eb5df1d62fe98c8e1b0341bdd119655553488300ec9d5b4036"}, + {"id":"ce6e32e3e17b6901d2cc70b60f3743e24f885bb6e9da6d88cff516079eac1883","pubkey":"726a1e261cc6474674e8285e3951b3bb139be9a773d1acf49dc868db861a1c11","created_at":1688234921,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"],["e","512962dbada5fd5015fc727a107d5c3f569662de67eab8e5da5a8065012cf11e","","reply"]],"content":"yet another one, testing 0.0.3","sig":"13bd41c4029c6a7ee41cb03d12e831e9e9b6e14d43a61c78a72657070b45385ba2ce98e8121049fbcbdb7b2dd777c36c7867ba70b5e6a4798a9b866910ae5b62"} + ] + """.trimIndent() + + val header = + """ + { + "content": "Not too long ago, I tried to paint a picture of what\na [vision for a value-enabled web][vew]\ncould look like. Now, only a couple of months later,\nall this stuff is being built. On nostr, and on lightning. Orange and\npurple, a match made in heaven.\n\nIt goes without saying that I'm beyond delighted. What a time to be alive!\n\n## nostr\n\nHere's the thing that nostr got right, and it's the same thing that\nBitcoin got right: information is easy to spread and hard to stifle.[^fn-stifle]\nInformation can be copied quickly and perfectly, which is, I believe,\nthe underlying reason for its desire to be free.\n\n[^fn-stifle]: That's a [Satoshi quote][stifle], of course: \"Bitcoin's solution is to use a peer-to-peer network to check for double-spending. In a nutshell, the network works like a distributed timestamp server, stamping the first transaction to spend a coin. It takes advantage of the nature of information being easy to spread but hard to stifle.\"\n\n[stifle]: https://satoshi.nakamotoinstitute.org/posts/p2pfoundation/1/\n\nEasy to spread, hard to stifle. That's the base reality of the nature\nof information. As always, the smart thing is to work with nature, not\nagainst it.[^1] That's what's beautiful about the orange coin and\nthe purple ostrich: both manage to work with the peculiarities of\ninformation, not against them. Both realize that information can and should be\ncopied, as it can be perfectly read and easily spread, always. Both understand\nthat resistance to censorship comes from writing to many places, making the cost\nof deletion prohibitive.\n\n> Information does not just want to be free,\n> it longs to be free. Information expands to fill the available\n> storage space. Information is Rumor's younger, stronger cousin;\n> Information is fleeter of foot, has more eyes, knows more, and\n> understands less than Rumor.\n>\n> Eric Hughes, [A Cypherpunk's Manifesto][manifesto]\n\n[manifesto]: https://nakamotoinstitute.org/static/docs/cypherpunk-manifesto.txt\n\nNostr is quickly establishing itself as a base layer for information exchange,\none that is identity-native and value-enabled. It is distinctly different from\nsystems that came before it, just like Bitcoin is distinctly different from\nmonies that came before it.\n\nAs of today, the focus of nostr is mostly on short text notes, the so-called\n\"type 1\" events more commonly known as *tweets*.[^fn-kinds] However, as you should be aware\nby now, nostr is way more than just an alternative to twitter. It is a new\nparadigm. Change the note kind from `1` to `30023` and you don't have an\nalternative to Twitter, but a replacement for Medium, Substack, and all the\nother long-form platforms. I believe that special-purpose clients that focus on\ncertain content types will emerge over time, just like we have seen the\nemergence of special-purpose platforms in the Web 2.0 era. This time, however,\nthe network effects are cumulative, not separate. A new paradigm.\n\nLet me now turn to one such special-purpose client, a nostr-based reading app.\n\n[^fn-kinds]: Refer to the various NIPs to discover the multitude of [event kinds][kinds] defined by the protocol.\n\n[kinds]: https://github.com/nostr-protocol/nips#event-kinds\n[nip23]: https://github.com/nostr-protocol/nips/blob/master/23.md\n\n## Reading\n\nI'm constantly surprised that, even though most people do read a lot\nonline, very few people seem to have a reading workflow or reading\ntools.\n\nWhy that is is anyone's guess, but maybe the added value of such tools\nis not readily apparent. You can just read the stuff right there, on the\nad-ridden, dead-ugly site, right? Why should you sign up for another\nsite, use another app, or bind yourself to another closed platform?\n\nThat's a fair point, but the success of Medium and Substack shows that\nthere is an appetite for clean reading and writing, as well as providing\navenues for authors to get paid for their writing (and a willingness of\nreaders to support said authors, just because).\n\nThe problem is, of course, that all of these platforms are *platforms*,\nwhich is to say, walled gardens that imprison readers and writers alike.\nWorse than that: they are fiat platforms, which means that\npermissionless value-flows are not only absent from their DNA, they are\noutright impossible.[^2]\n\nNostr fixes this.\n\n![Nostriches like to read, or so I've heard](https://dergigi.com/assets/images/bitcoin/2023-04-04-purple-text-orange-highlights/nostrich-reading-a-newspaper.jpg)\n\nThe beauty of nostr is that it is not a platform. It's a protocol,\nwhich means that you don't have to sign up for it---you can create an\nidentity yourself. You don't have to ask for permission; you just *do*,\nwithout having to rely on the benevolence of whatever dictator is in\ncharge of the platform right now.\n\nNostr is *not* a platform, and yet, powerful tools and services can be\nbuilt and monetized on top of it. This is good for users, good for\nservice providers, and good for the network(s) at large. Win-win-win.\n\nSo what am I talking about, exactly? How can nostr improve everyone's\nreading (and writing) experience?\n\nAllow me to paint a (rough) picture of what I have in mind. Nostr\nalready supports private and public bookmarks, so let's start from\nthere.\n\nImagine a special-purpose client that scans all your bookmarks for long-form\ncontent.[^fn-urls] Everything that you marked to be read later is shown in an orderly\nfashion, which is to say searchable, sortable, filterable, and displayed without\ndistractions. Voilà, you have yourself a reading app. That's, in essence, how\nPocket, Readwise, and other reading apps work. But all these apps are walled\ngardens without much interoperability and without direct monetization.\n\n[^fn-urls]: In the nostr world long-form content is simply markdown as defined in [NIP-23][nip23], but it could also be a link to an article or PDF, which in turn could get [converted into markdown][readability] and posted as an event to a special relay.\n\n[readability]: https://github.com/mozilla/readability\n\nBitcoin fixes the direct monetization part.[^fn-v4v] Nostr fixes the interoperability part.\n\n[^fn-v4v]: ...because Bitcoin makes [V4V][busking] practical. (Paywalls are not the way.)\n\nAlright, we got ourselves a boring reading app. Great. Now, imagine that\nusers are able to highlight passages. These highlights, just like\nbookmarks now, could be private or public. When shared publicly,\nsomething interesting emerges: an overlay on existing content, a lens on\nthe written Web. In other words: *swarm highlights*.\n\nImagine a visual overlay of all public highlights, automatically shining\na light on what the swarm of readers found most useful, insightful,\nfunny, etc.\n\n![Swarm Highlights](https://dergigi.com/assets/images/bitcoin/2023-04-04-purple-text-orange-highlights/highlights.png)\n\nFurther, imagine the possibility of sharing these highlights as a \"type 1\" event\nwith one click, automatically tagging the highlighter(s)---as well as the\nauthor, of course---so that eventual sat-flows can be split and forwarded\nautomatically.\n\n![Automated value splits](https://dergigi.com/assets/images/bitcoin/2023-04-04-purple-text-orange-highlights/sat-flows.png)\n\nVoilà, you have a system that allows for value to flow back to those who\nprovide it, be it authors, editors, curators, or readers that willingly\nslog through the information jungle to share and highlight the best\nstuff (which is a form of curation, of course).\n\nZaps make nostr a defacto address book[^fn-pp] of payment information, which is\nto say lightning addresses, as of now. Thanks to [nostr wallet connect][nwc] (among\nother developments), sending sats ~~will soon be~~ is already as\nfrictionless as leaving a like.\n\n[^fn-pp]: The Yellow Pages are dead, long live [The Purple Pages](http://purplepag.es/)!\n\nValue-for-value and participatory payment flows are something that\ntraditional reading apps desperately lack, be it Pocket, Instapaper,\nReadwise, or the simple reading mode that is part of every browser.\n\nA neat side-effect of a more structured way to share passages of text is\nthat it enables semi-structured discussions around said\npassages---which could be another useful overlay inside\nspecial-purpose clients, providing context and further insights.[^5]\n\nFurther, imagine the option of seamlessly switching from text-on-screen\nto text-to-speech, allowing the user to stream sats if desired, as\nPodcasting 2.0 clients already do.[^3]\n\nImagine user-built curations of the best articles of the week, bundled\nneatly for your reading pleasure, incentivized by a small value split\nthat allows the curator to participate in the flow of sats.\n\nYou get the idea.\n\nI'm sure that the various implementation details will be hashed out,\nbut as I see it, 90% of the stuff is already there. Maybe we'll need\nanother NIP or two, but I don't see a reason why this can't be\nbuilt---and, more importantly: I don't see a reason why it wouldn't\nbe sustainable for everyone involved.\n\nMost puzzle pieces are already there, and the rest of them can probably\nbe implemented by custom event types. From the point of view of nostr,\nmost everything is an event: bookmarks are events, highlights are\nevents, marking something as read is an event, and sharing an excerpt or\na highlight is an event. Public actions are out in the open, private\nactions are encrypted, the data is not in a silo, and everyone wins.\nEspecially the users, those who are at the edge of the network and\nusually lose out on the value generated.\n\nIn this case, the reading case, the users are mostly \"consumers\" of\ncontent. What changes from the producing perspective, the perspective of\nthe writer?\n\n## Writing\n\nBack to the one thing that nostr got right: information is easy to\nspread but hard to stifle. In addition to that, digital information can\nbe copied perfectly, which is why it shouldn't matter where stuff is\npublished in the first place.\n\nAllow me to repeat this point in all caps, for emphasis: **IT SHOULD NOT\nMATTER WHERE INFORMATION IS PUBLISHED**, and, maybe even more\nimportantly, it shouldn't matter if it is published in a hundred\ndifferent places at once.[^fn-torrents]\n\nWhat matters is trust and accuracy, which is to say, digital signatures\nand reputation. To translate this to nostr speak: because every event is\nsigned by default, as long as you trust the person behind the signature,\nit doesn't matter from which relay the information is fetched.\n\nThis is already true (or mostly true) on the regular web. Whether you\nread the internet archive version of an article or the version that is\npublished by an online magazine, the version on the author's website,\nor the version read by some guy that has read more about Bitcoin than\nanyone else you know[^fn-guy]---it's all the same, essentially. What matters\nis the information itself.\n\n[^fn-guy]: There is only one such guy, as we all know, and it's this Guy: nostr:npub1h8nk2346qezka5cpm8jjh3yl5j88pf4ly2ptu7s6uu55wcfqy0wq36rpev\n\nPractically speaking, the source of truth in a hypernostrized world is---you\nguessed it---an event. An event signed by the author, which allows for\nthe information to be wrapped in a tamper-proof manner, which in turn\nallows the information to spread far and wide---without it being\nhosted in one place.\n\nThe first clients that focus on long-form content already exist, and I expect\nmore clients to pop up over time.[^4] As mentioned before, one could easily\nimagine [prism-like value splits][prism] seamlessly integrated into these\nclients, splitting zaps automatically to compensate writers, editors,\nproofreaders, and illustrators in a V4V fashion. Further, one could imagine\nvarious compute-intensive services built into these special-purpose clients,\nsuch as GPT Ghostwriters, or writing aids such as Grammarly and the like. All\nthese services could be seamlessly paid for in sats, without the requirement of\nany sign-ups or the gathering of any user data. That's the beauty of [money\nproper][rediscovery].\n\n![A clean and simple reading and writing interface](https://dergigi.com/assets/images/bitcoin/2023-04-04-purple-text-orange-highlights/nostr-reader-and-writer.png)\n\nPlagiarism is one issue that needs to be dealt with, of course. Humans\nare greedy, and some humans are assholes. Neither bitcoin nor nostr\nfixes this. However, while plagiarism detection is not necessarily\ntrivial, it is also not impossible, especially if most texts are\npublished on nostr first. Nostr-based publishing tools allow for\nOpenTimestamp attestations thanks\nto [NIP-03](https://github.com/nostr-protocol/nips/blob/master/03.md),\nwhich in turn allows for plagiarism detection based on \"first seen\"\nlookups.\n\nThat's just one way to deal with the problem, of course. In any case,\nI'm confident that we'll figure it out.\n\n## Value\n\nI believe that in the open ~~attention~~ information economy we find\nourselves in, value will mostly derive from effective curation,\ndissemination, and transmission of information, *not* the exclusive\nownership of it.\n\nAlthough it is still early days,\nthe [statistics](https://stats.podcastindex.org/v4v) around Podcasting\n2.0 and [nostr zaps](https://zaplife.lol/) clearly show that (a) people\nare willing to monetarily reward content they care about, and (b) the\nwillingness to send sats *increases* as friction *decreases*.\n\nThe ingenious thing about boostagrams and zaps is that they are direct\nand visible, which is to say, public and interactive. They are neither\nregular transactions nor simple donations---they are something else\nentirely. An unforgable value signal, a special form of gratitude and\nappreciation.\n\nContrast that with a link to Paypal or Patreon: impersonal, slow,\nindirect, and friction-laden. It's the opposite of a super-charged\ninteraction.\n\nWhile today's information jungle increasingly presents itself in the\nform of (short) videos and (long-form) audio, I believe that we will see\na renaissance of the written word, especially if we manage to move away\nfrom an economy built around attention, towards an economy built upon\nvalue and insight.\n\nThe orange future now has a purple hue, and I believe that it will be as\nbright as ever. We just have a lot of building to do.\n\n---\n\n## Further Reading\n\n- [A Vision for a Value-Enabled Web][vew]\n- [The Freedom of Value][busking]\n- [The Rediscovery of Money][prism]\n- [Lightning Prisms][rediscovery]\n\n[vew]: https://dergigi.com/vew\n[prism]: https://dergigi.com/prism\n[rediscovery]: https://dergigi.com/rediscovery\n[busking]: https://dergigi.com/busking\n\n## NIPs and Resources\n\n- [Nostr Resources][nr]\n- [value4value.info](https://value4value.info/)\n- [nips.be](https://nips.be/)\n- [NIP-23: Long-form content](https://github.com/nostr-protocol/nips/blob/master/23.md)\n- [NIP-57: Event-specific zap markers](https://github.com/nostr-protocol/nips/blob/master/57.md)\n- [NIP-47: Nostr Wallet Connect](https://github.com/getAlby/nips/blob/master/47.md)\n- [NIP-03: OpenTimestamps attestations for events](https://github.com/nostr-protocol/nips/blob/master/03.md)\n\nOriginally published on [dergigi.com](https://dergigi.com/reader)\n\n---\n\n[^1]: Paywalls work against this nature, which is why I consider them misguided at best and incredibly retarded at worst.\n\n[^2]: Fiat doesn't work for the [value-enabled web][vew], as fiat rails can never be open and permissionless. Digital fiat is never money. It is---and always will be---[credit][rediscovery].\n\n[^3]: Whether the recipient is a text-to-speech service provider or a human narrator doesn't even matter too much, sats will flow just the same.\n\n[^4]: [BlogStack](https://blogstack.io/) and [Habla](https://habla.news/) being two of them.\n\n[^5]: Use a URI as the discussion base (instead of a highlight), and you got yourself a [Disqus](https://disqus.com/) in purple feathers!\n\n[^fn-torrents]: That's what torrents got right, and [ipfs] for that matter.\n\n[nr]: https://nostr-resources.com\n[nwc]: https://nwc.getalby.com/\n[ipfs]: https://fiatjaf.com/d5031e5b.html\n", + "created_at": 1680614039, + "id": "9517aa60334990596fdea4493c1bde429c55e9cefb84d8d88a6758c4e3ebcabc", + "kind": 30023, + "pubkey": "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93", + "sig": "c8c8c7ac8c288f6a6e1090e0e5f7eed85daaaba03eeab0a4936535f95ce066c08723bedcfb80a6af4941a9fafb9fe351950b9376d13ff0e81030cd4391840f04", + "tags": [ + ["d","1680612926599"], + ["title","Purple Text, Orange Highlights"], + ["summary","Using nostr as a base layer to create, consume, and monetize long-form content."], + ["published_at","1680613802"], + ["t","nostr"], + ["t","reading"], + ["t","writing"], + ["t","readability"], + ["t","highlights"], + ["t","pocket"], + ["t","instapaper"], + ["t","readwise"], + ["t","disqus"], + ["t","v4v"], + ["t","value4value"], + ["image","https://dergigi.com/assets/images/reader.jpg"], + ["p","b9e76546ba06456ed301d9e52bc49fa48e70a6bf2282be7a1ae72947612023dc"] + ] + } + """.trimIndent() + } @Test fun threadOrderTest() = runBlocking { val eventArray = - EventMapper.mapper.readValue>(db) as List + Event.fromJson(header) + JsonMapper.mapper.readValue>(db) as List + Event.fromJson(header) var counter = 0 eventArray.forEach { TestCase.assertTrue("${it.id} failed signature check", it.verify()) - LocalCache.verifyAndConsume(it, null) + LocalCache.justConsume(it, null, false) counter++ } @@ -138,11 +166,6 @@ class ThreadDualAxisChartAssemblerTest { null, ) - val account = Account(AccountSettings(KeyPair()), scope = CoroutineScope(Dispatchers.IO + SupervisorJob())) - withContext(Dispatchers.Main) { - val user = account.userProfile().live() - } - val filter = ThreadFeedFilter(account, naddr.toTag()) val calculatedFeed = filter.feed() diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/URIParserTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/URIParserTest.kt index a8c751dbda..db649c1982 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/URIParserTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/URIParserTest.kt @@ -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 diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt index 263361fb8d..16945e37c0 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt @@ -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 diff --git a/amethyst/src/androidTestPlay/java/com/vitorpamplona/amethyst/TranslationsTest.kt b/amethyst/src/androidTestPlay/java/com/vitorpamplona/amethyst/TranslationsTest.kt index 0c48f3f557..884daf31bf 100644 --- a/amethyst/src/androidTestPlay/java/com/vitorpamplona/amethyst/TranslationsTest.kt +++ b/amethyst/src/androidTestPlay/java/com/vitorpamplona/amethyst/TranslationsTest.kt @@ -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 diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt index 3b9ddaebe9..255b216ac5 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt @@ -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 diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushDistributorHandler.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushDistributorHandler.kt index b23eadd608..8f832704ec 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushDistributorHandler.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushDistributorHandler.kt @@ -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) } } diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt index 837138d012..eed9a8c81b 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt @@ -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") diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt index 6deb400c6f..bb25f81f2e 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt @@ -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 diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt index 732d36109a..f830850aac 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt @@ -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 diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index 387368c5c8..b6e13bc24f 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -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 diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 7daf6a19e0..1adc0f6598 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -23,6 +23,9 @@ + + + @@ -115,12 +118,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + tools:replace="screenOrientation" + tools:ignore="DiscouragedApi" /> + android:exported="true" + tools:ignore="ExportedService"> @@ -157,6 +193,7 @@ @@ -165,4 +202,4 @@ - \ No newline at end of file + diff --git a/amethyst/src/main/java/androidx/compose/material3/adaptive/Posture.kt b/amethyst/src/main/java/androidx/compose/material3/adaptive/Posture.kt deleted file mode 100644 index 8331338f94..0000000000 --- a/amethyst/src/main/java/androidx/compose/material3/adaptive/Posture.kt +++ /dev/null @@ -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): Posture { - var isTableTop = false - val hingeList = mutableListOf() - @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 = 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.getBounds(predicate: HingeInfo.() -> Boolean): List = - @Suppress("ListIterator") - mapNotNull { if (it.predicate()) it.bounds else null } diff --git a/amethyst/src/main/java/androidx/compose/material3/adaptive/WindowAdaptiveInfo.kt b/amethyst/src/main/java/androidx/compose/material3/adaptive/WindowAdaptiveInfo.kt deleted file mode 100644 index 056c1e154b..0000000000 --- a/amethyst/src/main/java/androidx/compose/material3/adaptive/WindowAdaptiveInfo.kt +++ /dev/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> { - 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() } - }.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)" -} diff --git a/amethyst/src/main/java/androidx/compose/material3/pullrefresh/PullRefresh.kt b/amethyst/src/main/java/androidx/compose/material3/pullrefresh/PullRefresh.kt deleted file mode 100644 index 5594a8418f..0000000000 --- a/amethyst/src/main/java/androidx/compose/material3/pullrefresh/PullRefresh.kt +++ /dev/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.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)) -} diff --git a/amethyst/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicator.kt b/amethyst/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicator.kt deleted file mode 100644 index 5402915b82..0000000000 --- a/amethyst/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicator.kt +++ /dev/null @@ -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(300, easing = LinearEasing) diff --git a/amethyst/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicatorTransform.kt b/amethyst/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicatorTransform.kt deleted file mode 100644 index 6e8b9853ca..0000000000 --- a/amethyst/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicatorTransform.kt +++ /dev/null @@ -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 - } - } -} diff --git a/amethyst/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshState.kt b/amethyst/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshState.kt deleted file mode 100644 index 42a6a90f42..0000000000 --- a/amethyst/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshState.kt +++ /dev/null @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index e630f1b576..d115ad178d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -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()) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt index f0001431c4..7d3d0719b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt @@ -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 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 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()) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt index 7a90f89ae6..573d6bb8a0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 6ef2fe6738..c424f39386 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -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?> = MutableStateFlow(null) - private var cachedAccounts: MutableMap = mutableMapOf() + private val savedAccounts: MutableStateFlow?> = MutableStateFlow(null) + private val cachedAccounts: MutableMap = 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>(it) + JsonMapper.mapper.readValue>(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(it) } + getString(PrefKeys.SHARED_SETTINGS, "{}")?.let { JsonMapper.mapper.readValue(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>(PrefKeys.RELAYS) ?: emptySet() - val zapPaymentRequestServer = parseOrNull(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER) val defaultFileServer = parseOrNull(PrefKeys.DEFAULT_FILE_SERVER) ?: DEFAULT_MEDIA_SERVERS[0] @@ -537,83 +583,22 @@ object LocalPreferences { val latestDmRelayList = parseEventOrNull(PrefKeys.LATEST_DM_RELAY_LIST) val latestNip65RelayList = parseEventOrNull(PrefKeys.LATEST_NIP65_RELAY_LIST) val latestSearchRelayList = parseEventOrNull(PrefKeys.LATEST_SEARCH_RELAY_LIST) + val latestBlockedRelayList = parseEventOrNull(PrefKeys.LATEST_BLOCKED_RELAY_LIST) + val latestTrustedRelayList = parseEventOrNull(PrefKeys.LATEST_TRUSTED_RELAY_LIST) val latestMuteList = parseEventOrNull(PrefKeys.LATEST_MUTE_LIST) val latestPrivateHomeRelayList = parseEventOrNull(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST) val latestAppSpecificData = parseEventOrNull(PrefKeys.LATEST_APP_SPECIFIC_DATA) - - val syncedSettings = - if (latestAppSpecificData != null) { - null - } else { - // previous version. Delete this when ready. - val reactionChoices = parseOrNull>(PrefKeys.REACTION_CHOICES)?.ifEmpty { DefaultReactions } ?: DefaultReactions - val zapAmountChoices = parseOrNull>(PrefKeys.ZAP_AMOUNTS)?.ifEmpty { DefaultZapAmounts } ?: DefaultZapAmounts - - val languagePreferences = parseOrNull>(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(PrefKeys.LATEST_CHANNEL_LIST) + val latestCommunityList = parseEventOrNull(PrefKeys.LATEST_COMMUNITY_LIST) + val latestHashtagList = parseEventOrNull(PrefKeys.LATEST_HASHTAG_LIST) + val latestGeohashList = parseEventOrNull(PrefKeys.LATEST_GEOHASH_LIST) + val latestEphemeralList = parseEventOrNull(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(PrefKeys.TOR_SETTINGS) ?: TorSettings() - } + val torSettings = parseOrNull(PrefKeys.TOR_SETTINGS) ?: TorSettings() val lastReadPerRoute = parseOrNull>(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(value) + JsonMapper.mapper.readValue(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 } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ParallelUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ParallelUtils.kt deleted file mode 100644 index 5673cb7209..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ParallelUtils.kt +++ /dev/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 launchAndWaitAll( - items: List, - 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 tryAndWait( - timeoutMillis: Long = 10000, - crossinline asyncFunc: (Continuation) -> 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 collectSuccessfulOperations( - items: List, - runRequestFor: (T, (K) -> Unit) -> Unit, - output: MutableList = mutableListOf(), - onReady: suspend (List) -> 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) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt deleted file mode 100644 index 4eebd0271d..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt +++ /dev/null @@ -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) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 74bc5c4f7c..4962c47be3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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,40 +21,76 @@ package com.vitorpamplona.amethyst.model import android.util.Log -import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable -import androidx.lifecycle.LiveData -import androidx.lifecycle.asLiveData -import androidx.lifecycle.liveData -import androidx.lifecycle.switchMap -import com.fasterxml.jackson.module.kotlin.readValue -import com.fonfon.kgeohash.GeoHash import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.BuildConfig -import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.RichTextParser -import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource -import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache +import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatListDecryptionCache +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatListState +import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState +import com.vitorpamplona.amethyst.model.nip01UserMetadata.AccountOutboxRelayState +import com.vitorpamplona.amethyst.model.nip01UserMetadata.NotificationInboxRelayState +import com.vitorpamplona.amethyst.model.nip01UserMetadata.UserMetadataState +import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListOutboxOrProxyRelays +import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListState +import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowsPerOutboxRelay +import com.vitorpamplona.amethyst.model.nip03Timestamp.OtsState +import com.vitorpamplona.amethyst.model.nip17Dms.DmInboxRelayState +import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState +import com.vitorpamplona.amethyst.model.nip18Reposts.RepostAction +import com.vitorpamplona.amethyst.model.nip25Reactions.ReactionAction +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatListDecryptionCache +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatListState +import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState +import com.vitorpamplona.amethyst.model.nip38UserStatuses.UserStatusAction +import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState +import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState +import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState +import com.vitorpamplona.amethyst.model.nip51Lists.blockPeopleList.BlockPeopleListState +import com.vitorpamplona.amethyst.model.nip51Lists.blockPeopleList.PeopleListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState +import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListState +import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.muteList.MuteListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.muteList.MuteListState +import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.searchRelays.SearchRelayListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.searchRelays.SearchRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays.TrustedRelayListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays.TrustedRelayListState +import com.vitorpamplona.amethyst.model.nip56Reports.ReportAction +import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState +import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListDecryptionCache +import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListState +import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState +import com.vitorpamplona.amethyst.model.nip96FileStorage.FileStorageServerListState +import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState +import com.vitorpamplona.amethyst.model.privacyOptions.PrivacyState +import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState +import com.vitorpamplona.amethyst.model.serverList.MergedFollowPlusMineRelayListsState +import com.vitorpamplona.amethyst.model.serverList.MergedServerListState +import com.vitorpamplona.amethyst.model.serverList.TrustedRelayListsState +import com.vitorpamplona.amethyst.model.topNavFeeds.FeedDecryptionCaches +import com.vitorpamplona.amethyst.model.topNavFeeds.FeedTopNavFilterState +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxLoaderState +import com.vitorpamplona.amethyst.model.torState.TorRelayState import com.vitorpamplona.amethyst.service.location.LocationState -import com.vitorpamplona.amethyst.service.ots.OtsResolverBuilder +import com.vitorpamplona.amethyst.service.ots.OkHttpOtsResolverBuilder import com.vitorpamplona.amethyst.service.uploads.FileHeader -import com.vitorpamplona.amethyst.tryAndWait -import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS -import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName -import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType -import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.FollowSet -import com.vitorpamplona.amethyst.ui.tor.TorType -import com.vitorpamplona.ammolite.relays.Constants -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.Relay -import com.vitorpamplona.ammolite.relays.RelaySetupInfo -import com.vitorpamplona.ammolite.relays.RelaySetupInfoToConnect -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.blossom.BlossomAuthorizationEvent -import com.vitorpamplona.quartz.blossom.BlossomServersEvent import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent -import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent @@ -76,34 +112,32 @@ import com.vitorpamplona.quartz.experimental.profileGallery.dimension import com.vitorpamplona.quartz.experimental.profileGallery.fromEvent import com.vitorpamplona.quartz.experimental.profileGallery.hash import com.vitorpamplona.quartz.experimental.profileGallery.mimeType +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.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.downloadFirstEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNote -import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedATags import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses -import com.vitorpamplona.quartz.nip01Core.tags.events.isTaggedEvent import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEventIds import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.hasAnyTaggedUser -import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds import com.vitorpamplona.quartz.nip01Core.tags.references.references -import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip02FollowList.ReadWrite -import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag -import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent -import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver +import com.vitorpamplona.quartz.nip04Dm.PrivateDMCache import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip04Dm.messages.reply import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent @@ -112,10 +146,8 @@ import com.vitorpamplona.quartz.nip10Notes.content.findHashtags import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris import com.vitorpamplona.quartz.nip10Notes.content.findURLs import com.vitorpamplona.quartz.nip17Dm.NIP17Factory -import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes @@ -123,50 +155,42 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.Entity import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay import com.vitorpamplona.quartz.nip19Bech32.entities.NSec -import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emojis -import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.taggedEmojis -import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip37Drafts.DraftBuilder import com.vitorpamplona.quartz.nip37Drafts.DraftEvent -import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent +import com.vitorpamplona.quartz.nip37Drafts.DraftEventCache import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.Response -import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.GeneralListEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent -import com.vitorpamplona.quartz.nip56Reports.ReportEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.PrivateZapCache import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip68Picture.PictureMeta import com.vitorpamplona.quartz.nip68Picture.pictureIMeta import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoMeta import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent -import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryRequestEvent import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nip92IMeta.imetas @@ -179,1050 +203,230 @@ import com.vitorpamplona.quartz.nip94FileMetadata.magnet import com.vitorpamplona.quartz.nip94FileMetadata.mimeType import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag -import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent -import com.vitorpamplona.quartz.utils.DualCase +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent 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.MutableStateFlow +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.combineTransform -import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.transformLatest -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext -import org.czeal.rfc3986.URIReference import java.math.BigDecimal -import java.util.Base64 import java.util.Locale -import kotlin.coroutines.cancellation.CancellationException -import kotlin.coroutines.resume @OptIn(DelicateCoroutinesApi::class) @Stable class Account( val settings: AccountSettings = AccountSettings(KeyPair()), - val signer: NostrSigner = settings.createSigner(), + val signer: NostrSigner, + geolocationFlow: StateFlow, + val cache: LocalCache, + val client: NostrClient, val scope: CoroutineScope, ) { - companion object { - const val APP_SPECIFIC_DATA_D_TAG = "AmethystSettings" - } - - var transientHiddenUsers: MutableStateFlow> = MutableStateFlow(setOf()) - - data class PaymentRequest( - val relayUrl: String, - val description: String, - ) - - var transientPaymentRequestDismissals: Set = emptySet() - val transientPaymentRequests: MutableStateFlow> = MutableStateFlow(emptySet()) - - @Immutable - class LiveFollowList( - val authors: Set = emptySet(), - val authorsPlusMe: Set, - val hashtags: Set = emptySet(), - val geotags: Set = emptySet(), - val addresses: Set = emptySet(), - ) - - class FeedsBaseFlows( - val listName: String, - val peopleList: StateFlow = MutableStateFlow(NoteState(Note(" "))), - val kind3: StateFlow = MutableStateFlow(null), - val location: StateFlow = MutableStateFlow(null), - ) - - val connectToRelaysFlow = - combineTransform( - getNIP65RelayListFlow(), - getDMRelayListFlow(), - getSearchRelayListFlow(), - getPrivateOutboxRelayListFlow(), - userProfile().flow().relays.stateFlow, - ) { nip65RelayList, dmRelayList, searchRelayList, privateOutBox, userProfile -> - checkNotInMainThread() - emit( - normalizeAndCombineRelayListsWithFallbacks( - kind3RelayList = kind3Relays(), - newDMRelayEvent = dmRelayList.note.event as? ChatMessageRelayListEvent, - searchRelayEvent = searchRelayList.note.event as? SearchRelayListEvent, - privateOutboxRelayEvent = privateOutBox.note.event as? PrivateOutboxRelayListEvent, - nip65RelayEvent = nip65RelayList.note.event as? AdvertisedRelayListEvent, - ).toTypedArray(), - ) - } - - private fun normalizeAndCombineRelayListsWithFallbacks( - kind3RelayList: Array? = null, - newDMRelayEvent: ChatMessageRelayListEvent? = null, - searchRelayEvent: SearchRelayListEvent? = null, - privateOutboxRelayEvent: PrivateOutboxRelayListEvent? = null, - nip65RelayEvent: AdvertisedRelayListEvent? = null, - localRelayList: Set? = null, - ) = normalizeAndCombineRelayLists( - baseRelaySet = kind3RelayList ?: convertLocalRelays(), - newDMRelayEvent = newDMRelayEvent ?: settings.backupDMRelayList, - searchRelayEvent = searchRelayEvent ?: settings.backupSearchRelayList, - privateOutboxRelayEvent = privateOutboxRelayEvent ?: settings.backupPrivateHomeRelayList, - nip65RelayEvent = nip65RelayEvent ?: settings.backupNIP65RelayList, - localRelayList = localRelayList ?: settings.localRelayServers, - ) - - private fun normalizeAndCombineRelayLists( - baseRelaySet: Array, - newDMRelayEvent: ChatMessageRelayListEvent?, - searchRelayEvent: SearchRelayListEvent?, - privateOutboxRelayEvent: PrivateOutboxRelayListEvent?, - nip65RelayEvent: AdvertisedRelayListEvent?, - localRelayList: Set, - ): List { - val newDMRelaySet = newDMRelayEvent?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: emptySet() - val searchRelaySet = (searchRelayEvent?.relays() ?: DefaultSearchRelayList).map { RelayUrlFormatter.normalize(it) }.toSet() - val nip65RelaySet = - nip65RelayEvent?.relays()?.map { - AdvertisedRelayListEvent.AdvertisedRelayInfo( - RelayUrlFormatter.normalize(it.relayUrl), - it.type, - ) - } - val privateOutboxRelaySet = privateOutboxRelayEvent?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: emptySet() - val localRelaySet = localRelayList.map { RelayUrlFormatter.normalize(it) }.toSet() - - return combineRelayLists( - baseRelaySet = baseRelaySet, - newDMRelaySet = newDMRelaySet, - searchRelaySet = searchRelaySet, - privateOutboxRelaySet = privateOutboxRelaySet, - nip65RelaySet = nip65RelaySet, - localRelaySet = localRelaySet, - ) - } - - private fun combineRelayLists( - baseRelaySet: Array, - newDMRelaySet: Set, - searchRelaySet: Set, - privateOutboxRelaySet: Set, - nip65RelaySet: List?, - localRelaySet: Set, - ): List { - // ------ - // DMs - // ------ - var mappedRelaySet = - baseRelaySet.map { - if (newDMRelaySet.contains(it.url)) { - RelaySetupInfo(it.url, true, true, it.feedTypes + FeedType.PRIVATE_DMS) - } else { - it - } - } - - newDMRelaySet.forEach { newUrl -> - if (mappedRelaySet.none { it.url == newUrl }) { - mappedRelaySet = mappedRelaySet + - RelaySetupInfo( - newUrl, - true, - true, - setOf( - FeedType.PRIVATE_DMS, - ), - ) - } - } - - // ------ - // SEARCH - // ------ - - mappedRelaySet = - mappedRelaySet.map { - if (searchRelaySet.contains(it.url)) { - RelaySetupInfo(it.url, true, it.write || false, it.feedTypes + FeedType.SEARCH) - } else { - it - } - } - - searchRelaySet.forEach { newUrl -> - if (mappedRelaySet.none { it.url == newUrl }) { - mappedRelaySet = mappedRelaySet + - RelaySetupInfo( - newUrl, - true, - false, - setOf( - FeedType.SEARCH, - ), - ) - } - } - - // -------------- - // PRIVATE OUTBOX - // -------------- - - mappedRelaySet = - mappedRelaySet.map { - if (privateOutboxRelaySet.contains(it.url)) { - RelaySetupInfo(it.url, true, true, it.feedTypes + setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL, FeedType.PRIVATE_DMS)) - } else { - it - } - } - - privateOutboxRelaySet.forEach { newUrl -> - if (mappedRelaySet.none { it.url == newUrl }) { - mappedRelaySet = mappedRelaySet + - RelaySetupInfo( - newUrl, - true, - true, - setOf( - FeedType.FOLLOWS, - FeedType.PUBLIC_CHATS, - FeedType.GLOBAL, - FeedType.PRIVATE_DMS, - ), - ) - } - } - - // -------------- - // Local Storage - // -------------- - - mappedRelaySet = - mappedRelaySet.map { - if (localRelaySet.contains(it.url)) { - RelaySetupInfo(it.url, true, true, it.feedTypes + setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL, FeedType.PRIVATE_DMS)) - } else { - it - } - } - - localRelaySet.forEach { newUrl -> - if (mappedRelaySet.none { it.url == newUrl }) { - mappedRelaySet = mappedRelaySet + - RelaySetupInfo( - newUrl, - true, - true, - setOf( - FeedType.FOLLOWS, - FeedType.PUBLIC_CHATS, - FeedType.GLOBAL, - FeedType.PRIVATE_DMS, - ), - ) - } - } - - // -------------- - // NIP-65 Public Inbox/Outbox - // -------------- - - mappedRelaySet = - mappedRelaySet.map { relay -> - val nip65setup = nip65RelaySet?.firstOrNull { relay.url == it.relayUrl } - if (nip65setup != null) { - val write = nip65setup.type == AdvertisedRelayListEvent.AdvertisedRelayType.BOTH || nip65setup.type == AdvertisedRelayListEvent.AdvertisedRelayType.WRITE - - RelaySetupInfo( - relay.url, - true, - relay.write || write, - relay.feedTypes + - setOf( - FeedType.FOLLOWS, - FeedType.GLOBAL, - FeedType.PUBLIC_CHATS, - ), - ) - } else { - relay - } - } - - nip65RelaySet?.forEach { newNip65Setup -> - if (mappedRelaySet.none { it.url == newNip65Setup.relayUrl }) { - val write = newNip65Setup.type == AdvertisedRelayListEvent.AdvertisedRelayType.BOTH || newNip65Setup.type == AdvertisedRelayListEvent.AdvertisedRelayType.WRITE - - mappedRelaySet = mappedRelaySet + - RelaySetupInfo( - newNip65Setup.relayUrl, - true, - write, - setOf( - FeedType.FOLLOWS, - FeedType.PUBLIC_CHATS, - ), - ) - } - } - return mappedRelaySet - } - - val connectToRelays = - connectToRelaysFlow - .flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - normalizeAndCombineRelayListsWithFallbacks( - kind3Relays(), - getDMRelayList(), - getSearchRelayList(), - getPrivateOutboxRelayList(), - getNIP65RelayList(), - ).toTypedArray(), - ) - - val connectToRelaysWithProxy = - combineTransform( - connectToRelays, - settings.torSettings.torType, - settings.torSettings.onionRelaysViaTor, - settings.torSettings.trustedRelaysViaTor, - ) { relays, torType, useTorForOnionRelays, useTorForTrustedRelays -> - emit( - relays - .map { - RelaySetupInfoToConnect( - it.url, - torType != TorType.OFF && checkLocalHostOnionAndThen(it.url, useTorForOnionRelays, useTorForTrustedRelays), - it.read, - it.write, - it.feedTypes, - ) - }.toTypedArray(), - ) - }.flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - normalizeAndCombineRelayListsWithFallbacks( - kind3Relays(), - getDMRelayList(), - getSearchRelayList(), - getPrivateOutboxRelayList(), - getNIP65RelayList(), - ).map { - RelaySetupInfoToConnect( - it.url, - settings.torSettings.torType.value != TorType.OFF && - checkLocalHostOnionAndThen( - it.url, - settings.torSettings.onionRelaysViaTor.value, - settings.torSettings.trustedRelaysViaTor.value, - ), - it.read, - it.write, - it.feedTypes, - ) - }.toTypedArray(), - ) - - fun buildFollowLists(latestContactList: ContactListEvent?): LiveFollowList { - // makes sure the output include only valid p tags - val verifiedFollowingUsers = latestContactList?.verifiedFollowKeySet() ?: emptySet() - - return LiveFollowList( - authors = verifiedFollowingUsers, - authorsPlusMe = verifiedFollowingUsers + signer.pubKey, - hashtags = - latestContactList - ?.unverifiedFollowTagSet() - ?.map { it.lowercase() } - ?.toSet() ?: emptySet(), - geotags = - latestContactList - ?.geohashes() - ?.toSet() ?: emptySet(), - addresses = - latestContactList - ?.verifiedFollowAddressSet() - ?.toSet() ?: emptySet(), - ) - } - - fun normalizeDMRelayListWithBackup(note: Note): Set { - val event = note.event as? ChatMessageRelayListEvent ?: settings.backupDMRelayList - return event?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: emptySet() - } - - val normalizedDmRelaySet = - getDMRelayListFlow() - .map { normalizeDMRelayListWithBackup(it.note) } - .flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - normalizeDMRelayListWithBackup(getDMRelayListNote()), - ) - - fun normalizePrivateOutboxRelayListWithBackup(note: Note): Set { - val event = note.event as? PrivateOutboxRelayListEvent ?: settings.backupPrivateHomeRelayList - return event?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: emptySet() - } - - val normalizedPrivateOutBoxRelaySet = - getPrivateOutboxRelayListFlow() - .map { normalizePrivateOutboxRelayListWithBackup(it.note) } - .flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - normalizePrivateOutboxRelayListWithBackup(getPrivateOutboxRelayListNote()), - ) - - @OptIn(ExperimentalCoroutinesApi::class) - val liveKind3FollowsFlow: Flow = - userProfile().flow().follows.stateFlow.transformLatest { - checkNotInMainThread() - emit(buildFollowLists(it.user.latestContactList)) - } - - val liveKind3Follows = - liveKind3FollowsFlow - .flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - buildFollowLists(userProfile().latestContactList ?: settings.backupContactList), - ) - - fun loadFlowsFor(listName: String): FeedsBaseFlows = - when (listName) { - GLOBAL_FOLLOWS -> FeedsBaseFlows(listName) - KIND3_FOLLOWS -> FeedsBaseFlows(listName, kind3 = liveKind3Follows) - AROUND_ME -> - FeedsBaseFlows( - listName, - location = Amethyst.instance.locationManager.geohashStateFlow, - ) - else -> { - val note = LocalCache.checkGetOrCreateAddressableNote(listName) - if (note != null) { - FeedsBaseFlows( - listName, - peopleList = - note - .flow() - .metadata.stateFlow, - ) - } else { - FeedsBaseFlows(listName) - } - } - } - - fun compute50kmLine(geoHash: GeoHash): List { - val hashes = mutableListOf() - - hashes.add(geoHash.toString()) - - var currentGeoHash = geoHash - repeat(5) { - currentGeoHash = currentGeoHash.westernNeighbour - hashes.add(currentGeoHash.toString()) - } - - currentGeoHash = geoHash - repeat(5) { - currentGeoHash = currentGeoHash.easternNeighbour - hashes.add(currentGeoHash.toString()) - } - - return hashes - } - - fun compute50kmRange(geoHash: GeoHash): List { - val hashes = mutableListOf() - - hashes.addAll(compute50kmLine(geoHash)) - - var currentGeoHash = geoHash - repeat(5) { - currentGeoHash = currentGeoHash.northernNeighbour - hashes.addAll(compute50kmLine(currentGeoHash)) - } - - currentGeoHash = geoHash - repeat(5) { - currentGeoHash = currentGeoHash.southernNeighbour - hashes.addAll(compute50kmLine(currentGeoHash)) - } - - return hashes - } - - suspend fun mapIntoFollowLists( - listName: String, - kind3: LiveFollowList?, - noteState: NoteState, - location: LocationState.LocationResult?, - ): LiveFollowList? = - if (listName == GLOBAL_FOLLOWS) { - null - } else if (listName == KIND3_FOLLOWS) { - kind3 - } else if (listName == AROUND_ME) { - val geohashResult = location ?: Amethyst.instance.locationManager.geohashStateFlow.value - if (geohashResult is LocationState.LocationResult.Success) { - // 2 neighbors deep = 25x25km - LiveFollowList( - authorsPlusMe = setOf(signer.pubKey), - geotags = compute50kmRange(geohashResult.geoHash).toSet(), - ) - } else { - LiveFollowList(authorsPlusMe = setOf(signer.pubKey)) - } - } else { - val peopleList = noteState.note.event as? GeneralListEvent - if (peopleList != null) { - waitToDecrypt(peopleList) ?: LiveFollowList(authorsPlusMe = setOf(signer.pubKey)) - } else { - LiveFollowList(authorsPlusMe = setOf(signer.pubKey)) - } - } - - @OptIn(ExperimentalCoroutinesApi::class) - fun combinePeopleListFlows(peopleListFollowsSource: Flow): Flow = - peopleListFollowsSource - .transformLatest { listName -> - val followList = loadFlowsFor(listName) - emitAll( - combine(followList.kind3, followList.peopleList, followList.location) { kind3, peopleList, location -> - mapIntoFollowLists(followList.listName, kind3, peopleList, location) - }, - ) - } - - val liveHomeFollowLists: StateFlow by lazy { - combinePeopleListFlows(settings.defaultHomeFollowList) - .flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - runBlocking { - loadAndCombineFlows(settings.defaultHomeFollowList.value) - }, - ) - } - - val liveServerList: StateFlow> by lazy { - combine(getFileServersListFlow(), getBlossomServersListFlow()) { nip96, blossom -> - mergeServerList(nip96.note.event as? FileServersEvent, blossom.note.event as? BlossomServersEvent) - }.flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - runBlocking { - mergeServerList(getFileServersList(), getBlossomServersList()) - }, - ) - } - - suspend fun loadAndCombineFlows(listName: String): LiveFollowList? { - val flows = loadFlowsFor(listName) - return mapIntoFollowLists( - flows.listName, - flows.kind3.value, - flows.peopleList.value, - flows.location.value, - ) - } - - /** - * filter onion and local host from write relays - * for each user pubkey, a list of valid relays. - */ - private fun assembleAuthorsPerWriteRelay( - userList: Map>, - hasOnionConnection: Boolean = false, - ): Map> { - checkNotInMainThread() - - val authorsPerRelayUrl = mutableMapOf>() - val relayUrlsPerAuthor = mutableMapOf>() - - userList.forEach { userWriteRelayListPair -> - userWriteRelayListPair.value.forEach { relayUrl -> - if (!RelayUrlFormatter.isLocalHost(relayUrl) && (hasOnionConnection || !RelayUrlFormatter.isOnion(relayUrl))) { - RelayUrlFormatter.normalizeOrNull(relayUrl)?.let { normRelayUrl -> - val userSet = authorsPerRelayUrl[normRelayUrl] - if (userSet != null) { - userSet.add(userWriteRelayListPair.key) - } else { - authorsPerRelayUrl[normRelayUrl] = mutableSetOf(userWriteRelayListPair.key) - } - - val relaySet = authorsPerRelayUrl[userWriteRelayListPair.key] - if (relaySet != null) { - relaySet.add(normRelayUrl) - } else { - relayUrlsPerAuthor[userWriteRelayListPair.key] = mutableSetOf(normRelayUrl) - } - } - } - } - } - - // for each relay, authors that only use this relay go first. - // then keeps order by pubkey asc - val comparator = compareByDescending { relayUrlsPerAuthor[it]?.size ?: 0 }.thenBy { it } - - return authorsPerRelayUrl.mapValues { - it.value.sortedWith(comparator) - } - } - - fun authorsPerRelay( - followsNIP65RelayLists: List, - defaultRelayList: List, - torType: TorType, - ): Map> = authorsPerRelay(followsNIP65RelayLists, defaultRelayList, torType != TorType.OFF) - - fun authorsPerRelay( - followsNIP65RelayLists: List, - defaultRelayList: List, - acceptOnion: Boolean, - ): Map> { - checkNotInMainThread() - - val defaultSet = defaultRelayList.toSet() - - return assembleAuthorsPerWriteRelay( - followsNIP65RelayLists - .mapNotNull - { - val author = (it as? AddressableNote)?.address?.pubKeyHex - val event = (it.event as? AdvertisedRelayListEvent) - - if (event != null) { - val authorWriteRelays = - event.writeRelays().map { - RelayUrlFormatter.normalize(it) - } - - val commonRelaysToMe = authorWriteRelays.filter { it in defaultSet } - if (commonRelaysToMe.isNotEmpty()) { - event.pubKey to commonRelaysToMe - } else { - event.pubKey to defaultRelayList - } - } else { - if (author != null) { - author to defaultRelayList - } else { - Log.e("Account", "This author should NEVER be null. Note: ${it.idHex}") - null - } - } - }.toMap(), - hasOnionConnection = acceptOnion, - ) - } - - @OptIn(ExperimentalCoroutinesApi::class) - val liveHomeFollowListAdvertizedRelayListFlow: Flow?> = - liveHomeFollowLists - .transformLatest { followList -> - if (followList != null) { - emitAll(combine(followList.authorsPlusMe.map { getNIP65RelayListFlow(it) }) { it }) - } else { - emit(null) - } - } - - val liveHomeListAuthorsPerRelayFlow: Flow>?> by lazy { - combineTransform(liveHomeFollowListAdvertizedRelayListFlow, connectToRelays, settings.torSettings.torType) { adverisedRelayList, existing, torStatus -> - if (adverisedRelayList != null) { - emit( - authorsPerRelay( - adverisedRelayList.map { it.note }, - existing.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url }, - torStatus, - ), - ) - } else { - emit(null) - } - } - } - - val liveHomeListAuthorsPerRelay: StateFlow>?> by lazy { - liveHomeListAuthorsPerRelayFlow.flowOn(Dispatchers.Default).stateIn( - scope, - SharingStarted.Eagerly, - authorsPerRelay( - liveHomeFollowLists.value?.authorsPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(), - connectToRelays.value.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url }, - settings.torSettings.torType.value, - ).ifEmpty { null }, - ) - } - - val liveNotificationFollowLists: StateFlow by lazy { - combinePeopleListFlows(settings.defaultNotificationFollowList) - .flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - runBlocking { - loadAndCombineFlows(settings.defaultNotificationFollowList.value) - }, - ) - } - - val liveStoriesFollowLists: StateFlow by lazy { - combinePeopleListFlows(settings.defaultStoriesFollowList) - .flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - runBlocking { - loadAndCombineFlows(settings.defaultStoriesFollowList.value) - }, - ) - } - - @OptIn(ExperimentalCoroutinesApi::class) - val liveStoriesFollowListAdvertizedRelayListFlow: Flow?> = - liveStoriesFollowLists - .transformLatest { followList -> - if (followList != null) { - emitAll(combine(followList.authorsPlusMe.map { getNIP65RelayListFlow(it) }) { it }) - } else { - emit(null) - } - } - - val liveStoriesListAuthorsPerRelayFlow: Flow>?> by lazy { - combineTransform(liveStoriesFollowListAdvertizedRelayListFlow, connectToRelays, settings.torSettings.torType) { adverisedRelayList, existing, torState -> - if (adverisedRelayList != null) { - emit( - authorsPerRelay( - adverisedRelayList.map { it.note }, - existing.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url }, - torState, - ), - ) - } else { - emit(null) - } - } - } - - val liveStoriesListAuthorsPerRelay: StateFlow>?> by lazy { - liveStoriesListAuthorsPerRelayFlow.flowOn(Dispatchers.Default).stateIn( - scope, - SharingStarted.Eagerly, - authorsPerRelay( - liveStoriesFollowLists.value?.authorsPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(), - connectToRelays.value.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url }, - settings.torSettings.torType.value, - ).ifEmpty { null }, - ) - } - - val liveDiscoveryFollowLists: StateFlow by lazy { - combinePeopleListFlows(settings.defaultDiscoveryFollowList) - .flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - runBlocking { - loadAndCombineFlows(settings.defaultDiscoveryFollowList.value) - }, - ) - } - - @OptIn(ExperimentalCoroutinesApi::class) - val liveDiscoveryFollowListAdvertizedRelayListFlow: Flow?> = - liveDiscoveryFollowLists - .transformLatest { followList -> - if (followList != null) { - emitAll(combine(followList.authorsPlusMe.map { getNIP65RelayListFlow(it) }) { it }) - } else { - emit(null) - } - } - - val liveDiscoveryListAuthorsPerRelayFlow: Flow>?> by lazy { - combineTransform(liveDiscoveryFollowListAdvertizedRelayListFlow, connectToRelays, settings.torSettings.torType) { adverisedRelayList, existing, torState -> - if (adverisedRelayList != null) { - emit( - authorsPerRelay( - adverisedRelayList.map { it.note }, - existing.filter { it.read }.map { it.url }, - torState, - ), - ) - } else { - emit(null) - } - } - } - - val liveDiscoveryListAuthorsPerRelay: StateFlow>?> by lazy { - liveDiscoveryListAuthorsPerRelayFlow.flowOn(Dispatchers.Default).stateIn( - scope, - SharingStarted.Eagerly, - authorsPerRelay( - liveDiscoveryFollowLists.value?.authorsPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(), - connectToRelays.value.filter { it.read }.map { it.url }, - settings.torSettings.torType.value, - ).ifEmpty { null }, - ) - } - - private fun decryptLiveFollows( - listEvent: GeneralListEvent, - onReady: (LiveFollowList) -> Unit, - ) { - listEvent.privateTags(signer) { privateTagList -> - val users = (listEvent.taggedUserIds() + listEvent.filterUsers(privateTagList)).toSet() - onReady( - LiveFollowList( - authors = users, - authorsPlusMe = users + userProfile().pubkeyHex, - hashtags = (listEvent.hashtags() + listEvent.filterHashtags(privateTagList)).toSet(), - geotags = (listEvent.geohashes() + listEvent.filterGeohashes(privateTagList)).toSet(), - addresses = - (listEvent.taggedATags() + listEvent.filterATags(privateTagList)) - .map { it.toTag() } - .toSet(), - ), - ) - } - } - - fun decryptPeopleList( - event: GeneralListEvent, - onReady: (Array>) -> Unit, - ) = event.privateTags(signer, onReady) - - suspend fun waitToDecrypt(peopleListFollows: GeneralListEvent): LiveFollowList? = - tryAndWait { continuation -> - decryptLiveFollows(peopleListFollows) { - continuation.resume(it) - } - } - - @Immutable - class LiveHiddenUsers( - val hiddenUsers: Set, - val spammers: Set, - val hiddenWords: Set, - 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()) } - } - - suspend fun decryptPeopleList(event: PeopleListEvent?): PeopleListEvent.UsersAndWords { - if (event == null || !isWriteable()) return PeopleListEvent.UsersAndWords() - - return tryAndWait { continuation -> - event.publicAndPrivateUsersAndWords(signer) { - continuation.resume(it) - } - } ?: PeopleListEvent.UsersAndWords() - } - - suspend fun decryptMuteList(event: MuteListEvent?): PeopleListEvent.UsersAndWords { - if (event == null || !isWriteable()) return PeopleListEvent.UsersAndWords() - - return tryAndWait { continuation -> - event.publicAndPrivateUsersAndWords(signer) { - continuation.resume(it) - } - } ?: PeopleListEvent.UsersAndWords() - } - - suspend fun assembleLiveHiddenUsers( - blockList: Note, - muteList: Note, - transientHiddenUsers: Set, - showSensitiveContent: Boolean?, - ): LiveHiddenUsers { - val resultBlockList = decryptPeopleList(blockList.event as? PeopleListEvent) - val resultMuteList = decryptMuteList(muteList.event as? MuteListEvent) - - return LiveHiddenUsers( - hiddenUsers = resultBlockList.users + resultMuteList.users, - hiddenWords = resultBlockList.words + resultMuteList.words, - spammers = transientHiddenUsers, - showSensitiveContent = showSensitiveContent, - ) - } - - val flowHiddenUsers: StateFlow by lazy { - combineTransform( - getBlockListNote().flow().metadata.stateFlow, - getMuteListNote().flow().metadata.stateFlow, - transientHiddenUsers, - settings.syncedSettings.security.showSensitiveContent, - ) { blockList, muteList, transientHiddenUsers, showSensitiveContent -> - checkNotInMainThread() - emit(assembleLiveHiddenUsers(blockList.note, muteList.note, transientHiddenUsers, showSensitiveContent)) - }.flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - runBlocking { - assembleLiveHiddenUsers( - getBlockListNote(), - getMuteListNote(), - transientHiddenUsers.value, - settings.syncedSettings.security.showSensitiveContent.value, - ) - }, - ) - } - - val liveHiddenUsers = flowHiddenUsers.asLiveData() - - val decryptBookmarks: LiveData by lazy { - userProfile().live().innerBookmarks.switchMap { userState -> - liveData(Dispatchers.IO) { - if (userState.user.latestBookmarkList == null) { - emit(null) - } else { - emit( - tryAndWait { continuation -> - userState.user.latestBookmarkList?.privateTags(signer) { - continuation.resume(userState.user.latestBookmarkList) - } - }, - ) - } - } - } - } - - class EmojiMedia( - val code: String, - val url: MediaUrlImage, - ) - - fun getEmojiPackSelection(): EmojiPackSelectionEvent? = getEmojiPackSelectionNote().event as? EmojiPackSelectionEvent - - fun getEmojiPackSelectionFlow(): StateFlow = getEmojiPackSelectionNote().flow().metadata.stateFlow - - fun getEmojiPackSelectionAddress() = EmojiPackSelectionEvent.createAddress(userProfile().pubkeyHex) - - fun getEmojiPackSelectionNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(getEmojiPackSelectionAddress()) - - fun convertEmojiSelectionPack(selection: EmojiPackSelectionEvent?): List>? = - selection?.taggedAddresses()?.map { - LocalCache - .getOrCreateAddressableNote(it) - .flow() - .metadata.stateFlow - } - - @OptIn(ExperimentalCoroutinesApi::class) - val liveEmojiSelectionPack: StateFlow>?> by lazy { - getEmojiPackSelectionFlow() - .transformLatest { - emit(convertEmojiSelectionPack(it.note.event as? EmojiPackSelectionEvent)) - }.flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - convertEmojiSelectionPack(getEmojiPackSelection()), - ) - } - - fun convertEmojiPack(pack: EmojiPackEvent): List = - pack.taggedEmojis().map { - EmojiMedia(it.code, MediaUrlImage(it.url)) - } - - fun mergePack(list: Array): List = - list - .mapNotNull { - val ev = it.note.event as? EmojiPackEvent - if (ev != null) { - convertEmojiPack(ev) - } else { - null - } - }.flatten() - .distinctBy { it.url } - - @OptIn(ExperimentalCoroutinesApi::class) - val myEmojis by lazy { - liveEmojiSelectionPack - .transformLatest { emojiList -> - if (emojiList != null) { - emitAll( - combineTransform(emojiList) { - emit(mergePack(it)) - }, - ) - } else { - emit(emptyList()) - } - }.flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - mergePack(convertEmojiSelectionPack(getEmojiPackSelection())?.map { it.value }?.toTypedArray() ?: emptyArray()), - ) - } - - fun addPaymentRequestIfNew(paymentRequest: PaymentRequest) { - if ( - !this.transientPaymentRequests.value.contains(paymentRequest) && - !this.transientPaymentRequestDismissals.contains(paymentRequest) - ) { - this.transientPaymentRequests.value += paymentRequest - } - } - - fun dismissPaymentRequest(request: PaymentRequest) { - if (this.transientPaymentRequests.value.contains(request)) { - this.transientPaymentRequests.value -= request - this.transientPaymentRequestDismissals += request - } - } - private var userProfileCache: User? = null - fun userProfile(): User = userProfileCache ?: LocalCache.getOrCreateUser(signer.pubKey).also { userProfileCache = it } + fun userProfile(): User = userProfileCache ?: cache.getOrCreateUser(signer.pubKey).also { userProfileCache = it } + + val userMetadata = UserMetadataState(signer, cache, scope, settings) + + val nip47SignerState = NwcSignerState(signer, cache, scope, settings) + + val nip65RelayList = Nip65RelayListState(signer, cache, scope, settings) + val localRelayList = LocalRelayListState(signer, cache, scope, settings) + + val dmRelayList = DmRelayListState(signer, cache, scope, settings) + + val privateStorageDecryptionCache = PrivateStorageRelayListDecryptionCache(signer) + val privateStorageRelayList = PrivateStorageRelayListState(signer, cache, privateStorageDecryptionCache, scope, settings) + + val searchRelayListDecryptionCache = SearchRelayListDecryptionCache(signer) + val searchRelayList = SearchRelayListState(signer, cache, searchRelayListDecryptionCache, scope, settings) + + val trustedRelayListDecryptionCache = TrustedRelayListDecryptionCache(signer) + val trustedRelayList = TrustedRelayListState(signer, cache, trustedRelayListDecryptionCache, scope, settings) + + val proxyRelayListDecryptionCache = ProxyRelayListDecryptionCache(signer) + val proxyRelayList = ProxyRelayListState(signer, cache, proxyRelayListDecryptionCache, scope, settings) + + val broadcastRelayListDecryptionCache = BroadcastRelayListDecryptionCache(signer) + val broadcastRelayList = BroadcastRelayListState(signer, cache, broadcastRelayListDecryptionCache, scope, settings) + + val indexerRelayListDecryptionCache = IndexerRelayListDecryptionCache(signer) + val indexerRelayList = IndexerRelayListState(signer, cache, indexerRelayListDecryptionCache, scope, settings) + + val blockedRelayListDecryptionCache = BlockedRelayListDecryptionCache(signer) + val blockedRelayList = BlockedRelayListState(signer, cache, blockedRelayListDecryptionCache, scope, settings) + + val kind3FollowList = FollowListState(signer, cache, scope, settings) + + val ephemeralChatListDecryptionCache = EphemeralChatListDecryptionCache(signer) + val ephemeralChatList = EphemeralChatListState(signer, cache, ephemeralChatListDecryptionCache, scope, settings) + + val publicChatListDecryptionCache = PublicChatListDecryptionCache(signer) + val publicChatList = PublicChatListState(signer, cache, publicChatListDecryptionCache, scope, settings) + + val communityListDecryptionCache = CommunityListDecryptionCache(signer) + val communityList = CommunityListState(signer, cache, communityListDecryptionCache, scope, settings) + + val hashtagListDecryptionCache = HashtagListDecryptionCache(signer) + val hashtagList = HashtagListState(signer, cache, hashtagListDecryptionCache, scope, settings) + + val geohashListDecryptionCache = GeohashListDecryptionCache(signer) + val geohashList = GeohashListState(signer, cache, geohashListDecryptionCache, scope, settings) + + val muteListDecryptionCache = MuteListDecryptionCache(signer) + val muteList = MuteListState(signer, cache, muteListDecryptionCache, scope, settings) + + val peopleListDecryptionCache = PeopleListDecryptionCache(signer) + val blockPeopleList = BlockPeopleListState(signer, cache, peopleListDecryptionCache, scope) + + val hiddenUsers = HiddenUsersState(muteList.flow, blockPeopleList.flow, scope, settings) + + val bookmarkState = BookmarkListState(signer, cache, scope) + val emoji = EmojiPackState(signer, cache, scope) + + val appSpecific = AppSpecificState(signer, cache, scope, settings) + + val blossomServers = BlossomServerListState(signer, cache, scope, settings) + val fileStorageServers = FileStorageServerListState(signer, cache, scope, settings) + val serverLists = MergedServerListState(fileStorageServers, blossomServers, scope) + + // Relay settings + val outboxRelays = AccountOutboxRelayState(nip65RelayList, privateStorageRelayList, localRelayList, broadcastRelayList, scope) + val dmRelays = DmInboxRelayState(dmRelayList, nip65RelayList, privateStorageRelayList, localRelayList, scope) + val notificationRelays = NotificationInboxRelayState(nip65RelayList, localRelayList, scope) + + val trustedRelays = TrustedRelayListsState(nip65RelayList, privateStorageRelayList, localRelayList, dmRelayList, searchRelayList, trustedRelayList, broadcastRelayList, scope) + + // Follows Relays + val followOutboxesOrProxy = FollowListOutboxOrProxyRelays(kind3FollowList, blockedRelayList, proxyRelayList, cache, scope) + val followPlusAllMine = MergedFollowPlusMineRelayListsState(followOutboxesOrProxy, nip65RelayList, privateStorageRelayList, localRelayList, broadcastRelayList, indexerRelayList, scope) + + // keeps a cache of the outbox relays for each author + val followsPerRelay = FollowsPerOutboxRelay(kind3FollowList, blockedRelayList, proxyRelayList, cache, scope).flow + + // Merges all follow lists to create a single All Follows feed. + val allFollows = MergedFollowListsState(kind3FollowList, hashtagList, geohashList, communityList, scope) + + val privateDMDecryptionCache = PrivateDMCache(signer) + val privateZapsDecryptionCache = PrivateZapCache(signer) + val draftsDecryptionCache = DraftEventCache(signer) + + val chatroomList = cache.getOrCreateChatroomList(signer.pubKey) + + val privacyState = PrivacyState(settings) + val torRelayState = TorRelayState(trustedRelays, dmRelayList, settings, scope) + + val otsResolverBuilder: OkHttpOtsResolverBuilder = + OkHttpOtsResolverBuilder( + Amethyst.instance.okHttpClients, + privacyState::shouldUseTorForMoneyOperations, + Amethyst.instance.otsBlockHeightCache, + ) + + val otsState = OtsState(signer, cache, otsResolverBuilder, scope, settings) + + val feedDecryptionCaches = + FeedDecryptionCaches( + peopleListCache = peopleListDecryptionCache, + muteListCache = muteListDecryptionCache, + communityListCache = communityListDecryptionCache, + hashtagCache = hashtagListDecryptionCache, + geohashCache = geohashListDecryptionCache, + ) + + // App-ready Feeds + val liveHomeFollowLists: StateFlow = + FeedTopNavFilterState( + feedFilterListName = settings.defaultHomeFollowList, + allFollows = allFollows.flow, + locationFlow = geolocationFlow, + followsRelays = followPlusAllMine.flow, + blockedRelays = blockedRelayList.flow, + proxyRelays = proxyRelayList.flow, + caches = feedDecryptionCaches, + signer = signer, + scope = scope, + ).flow + + val liveHomeFollowListsPerRelay = OutboxLoaderState(liveHomeFollowLists, cache, scope).flow + + val liveStoriesFollowLists: StateFlow = + FeedTopNavFilterState( + feedFilterListName = settings.defaultStoriesFollowList, + allFollows = allFollows.flow, + locationFlow = geolocationFlow, + followsRelays = followPlusAllMine.flow, + blockedRelays = blockedRelayList.flow, + proxyRelays = proxyRelayList.flow, + caches = feedDecryptionCaches, + signer = signer, + scope = scope, + ).flow + + val liveStoriesFollowListsPerRelay = OutboxLoaderState(liveStoriesFollowLists, cache, scope).flow + + val liveDiscoveryFollowLists: StateFlow = + FeedTopNavFilterState( + feedFilterListName = settings.defaultDiscoveryFollowList, + allFollows = allFollows.flow, + locationFlow = geolocationFlow, + followsRelays = followPlusAllMine.flow, + blockedRelays = blockedRelayList.flow, + proxyRelays = proxyRelayList.flow, + caches = feedDecryptionCaches, + signer = signer, + scope = scope, + ).flow + + val liveDiscoveryFollowListsPerRelay = OutboxLoaderState(liveDiscoveryFollowLists, cache, scope).flow + + val liveNotificationFollowLists: StateFlow = + FeedTopNavFilterState( + feedFilterListName = settings.defaultNotificationFollowList, + allFollows = allFollows.flow, + locationFlow = geolocationFlow, + followsRelays = followPlusAllMine.flow, + blockedRelays = blockedRelayList.flow, + proxyRelays = proxyRelayList.flow, + caches = feedDecryptionCaches, + signer = signer, + scope = scope, + ).flow + + val liveNotificationFollowListsPerRelay = OutboxLoaderState(liveNotificationFollowLists, cache, scope).flow + + /* + val mergedTopFeedAuthorLists = + MergedTopFeedAuthorListsState( + liveHomeFollowListsPerRelay, + liveStoriesFollowListsPerRelay, + liveDiscoveryFollowListsPerRelay, + liveNotificationFollowListsPerRelay, + scope, + ).flow + + */ fun isWriteable(): Boolean = settings.isWriteable() - fun updateOptOutOptions( - warnReports: Boolean, - filterSpam: Boolean, - ): Boolean { - if (settings.updateOptOutOptions(warnReports, filterSpam)) { + suspend fun updateWarnReports(warnReports: Boolean): Boolean { + if (settings.updateWarnReports(warnReports)) { + sendNewAppSpecificData() + return true + } + return false + } + + suspend fun updateFilterSpam(filterSpam: Boolean): Boolean { + if (settings.updateFilterSpam(filterSpam)) { if (!settings.syncedSettings.security.filterSpamFromStrangers.value) { - transientHiddenUsers.update { - emptySet() - } + hiddenUsers.resetTransientUsers() } sendNewAppSpecificData() @@ -1231,22 +435,22 @@ class Account( return false } - fun updateShowSensitiveContent(show: Boolean?) { + suspend fun updateShowSensitiveContent(show: Boolean?) { if (settings.updateShowSensitiveContent(show)) { sendNewAppSpecificData() } } - fun changeReactionTypes(reactionSet: List) { + suspend fun changeReactionTypes(reactionSet: List) { if (settings.changeReactionTypes(reactionSet)) { sendNewAppSpecificData() } } - fun updateZapAmounts( + suspend fun updateZapAmounts( amountSet: List, selectedZapType: LnZapEvent.ZapType, - nip47Update: Nip47WalletConnect.Nip47URI?, + nip47Update: Nip47WalletConnect.Nip47URINorm?, ) { var changed = false @@ -1259,18 +463,18 @@ class Account( } } - fun toggleDontTranslateFrom(languageCode: String) { + suspend fun toggleDontTranslateFrom(languageCode: String) { settings.toggleDontTranslateFrom(languageCode) sendNewAppSpecificData() } - fun updateTranslateTo(languageCode: Locale) { + suspend fun updateTranslateTo(languageCode: Locale) { if (settings.updateTranslateTo(languageCode)) { sendNewAppSpecificData() } } - fun prefer( + suspend fun prefer( source: String, target: String, preference: String, @@ -1279,265 +483,36 @@ class Account( sendNewAppSpecificData() } - private fun sendNewAppSpecificData() { - sendNewAppSpecificData(settings.syncedSettings.toInternal()) - } - - private fun sendNewAppSpecificData(toInternal: AccountSyncedSettingsInternal) { - signer.nip44Encrypt(EventMapper.mapper.writeValueAsString(toInternal), signer.pubKey) { encrypted -> - AppSpecificDataEvent.create( - dTag = APP_SPECIFIC_DATA_D_TAG, - description = encrypted, - otherTags = emptyArray(), - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } - } - - fun sendKind3RelayList(relays: Map) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null && contactList.tags.isNotEmpty()) { - ContactListEvent.updateRelayList( - earlierVersion = contactList, - relayUse = relays, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } else { - ContactListEvent.createFromScratch( - followUsers = listOf(), - followTags = listOf(), - followGeohashes = listOf(), - followCommunities = listOf(), - followEvents = DefaultChannels.toList(), - relayUse = relays, - signer = signer, - ) { - // Keep this local to avoid erasing a good contact list. - // Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } - } - - suspend fun countFollowersOf(pubkey: HexKey): Int = LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkey) ?: false } - - suspend fun followerCount(): Int = countFollowersOf(signer.pubKey) - - 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, - ) { - if (!isWriteable()) return - - val latest = userProfile().latestMetadata - - val template = - if (latest != null) { - MetadataEvent.updateFromPast( - latest = latest, - name = name, - displayName = name, - picture = picture, - banner = banner, - website = website, - pronouns = pronouns, - about = about, - nip05 = nip05, - lnAddress = lnAddress, - lnURL = lnURL, - twitter = twitter, - mastodon = mastodon, - github = github, - ) - } else { - MetadataEvent.createNew( - name = name, - displayName = name, - picture = picture, - banner = banner, - website = website, - pronouns = pronouns, - about = about, - nip05 = nip05, - lnAddress = lnAddress, - lnURL = lnURL, - twitter = twitter, - mastodon = mastodon, - github = github, - ) - } - - signer.sign(template) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } - - fun reactionTo( - note: Note, - reaction: String, - ): List = note.reactedBy(userProfile(), reaction) - - fun hasBoosted(note: Note): Boolean = boostsTo(note).isNotEmpty() - - fun boostsTo(note: Note): List = note.boostedBy(userProfile()) - - fun hasReacted( - note: Note, - reaction: String, - ): Boolean = note.hasReacted(userProfile(), reaction) + private suspend fun sendNewAppSpecificData() = sendMyPublicAndPrivateOutbox(appSpecific.saveNewAppSpecificData()) suspend fun reactTo( note: Note, reaction: String, - ) { - if (!isWriteable()) return + ) = ReactionAction.reactTo( + note = note, + reaction = reaction, + by = userProfile(), + signer = signer, + onPublic = ::sendAutomatic, + onPrivate = ::broadcastPrivately, + ) - if (hasReacted(note, 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()?.let { - NIP17Factory().createReactionWithinGroup( - emojiUrl = emojiUrl, - originalNote = it, - to = users, - signer = signer, - ) { - broadcastPrivately(it) - } - } - - return - } - } - - note.toEventHint()?.let { - NIP17Factory().createReactionWithinGroup( - content = reaction, - originalNote = it, - to = users, - signer = signer, - ) { - broadcastPrivately(it) - } - } - return - } else { - if (reaction.startsWith(":")) { - val emojiUrl = EmojiUrlTag.decode(reaction) - if (emojiUrl != null) { - note.event?.let { - signer.sign( - ReactionEvent.build(emojiUrl, EventHintBundle(it, note.relayHintUrl())), - ) { - Amethyst.instance.client.send(it) - LocalCache.consume(it) - } - } - - return - } - } - - note.toEventHint()?.let { - signer.sign( - ReactionEvent.build(reaction, it), - ) { - Amethyst.instance.client.send(it) - LocalCache.consume(it) - } - } - } - } - - fun createZapRequestFor( - note: Note, + suspend fun createZapRequestFor( + event: Event, pollOption: Int?, message: String = "", zapType: LnZapEvent.ZapType, toUser: User?, - additionalRelays: Set? = null, - onReady: (LnZapRequestEvent) -> Unit, - ) { - if (!isWriteable()) return - - note.event?.let { event -> - LnZapRequestEvent.create( - event, - relays = getReceivingRelays() + (additionalRelays ?: emptySet()), - signer, - pollOption, - message, - zapType, - toUser?.pubkeyHex, - onReady = onReady, - ) - } - } - - fun getReceivingRelays(): Set = - getNIP65RelayList()?.readRelays()?.toSet() - ?: userProfile() - .latestContactList - ?.relays() - ?.filter { it.value.read } - ?.keys - ?.ifEmpty { null } - ?: settings.localRelays - .filter { it.read } - .map { it.url } - .toSet() - - fun hasWalletConnectSetup(): Boolean = settings.zapPaymentRequest != null - - fun isNIP47Author(pubkeyHex: String?): Boolean = (getNIP47Signer().pubKey == pubkeyHex) - - fun getNIP47Signer(): NostrSigner = - settings.zapPaymentRequest - ?.secret - ?.hexToByteArray() - ?.let { NostrSignerInternal(KeyPair(it)) } - ?: signer - - fun decryptZapPaymentResponseEvent( - zapResponseEvent: LnZapPaymentResponseEvent, - onReady: (Response) -> Unit, - ) { - val myNip47 = settings.zapPaymentRequest ?: return - - val signer = - myNip47.secret?.hexToByteArray()?.let { NostrSignerInternal(KeyPair(it)) } ?: signer - - zapResponseEvent.response(signer, onReady) - } + additionalRelays: Set? = null, + ) = LnZapRequestEvent.create( + zappedEvent = event, + relays = nip65RelayList.inboxFlow.value + (additionalRelays ?: emptySet()), + signer = signer, + pollOption = pollOption, + message = message, + zapType = zapType, + toUserPubHex = toUser?.pubkeyHex, + ) suspend fun calculateIfNoteWasZappedByAccount( zappedNote: Note?, @@ -1546,613 +521,386 @@ class Account( zappedNote?.isZappedBy(userProfile(), this, onWasZapped) } - suspend fun calculateZappedAmount( - zappedNote: Note?, - onReady: (BigDecimal) -> Unit, - ) { - zappedNote?.zappedAmountWithNWCPayments(getNIP47Signer(), onReady) - } + suspend fun calculateZappedAmount(zappedNote: Note): BigDecimal = zappedNote.zappedAmountWithNWCPayments(nip47SignerState) - fun sendZapPaymentRequestFor( + suspend fun sendZapPaymentRequestFor( bolt11: String, zappedNote: Note?, - onSent: () -> Unit, onResponse: (Response?) -> Unit, ) { - if (!isWriteable()) return - - settings.zapPaymentRequest?.let { nip47 -> - val signer = - nip47.secret?.hexToByteArray()?.let { NostrSignerInternal(KeyPair(it)) } ?: signer - - LnZapPaymentRequestEvent.create(bolt11, nip47.pubKeyHex, signer) { event -> - val wcListener = - NostrLnZapPaymentResponseDataSource( - fromServiceHex = nip47.pubKeyHex, - toUserHex = event.pubKey, - replyingToHex = event.id, - authSigner = signer, - ) - wcListener.startSync() - - LocalCache.consume(event, zappedNote) { it.response(signer) { onResponse(it) } } - - Amethyst.instance.client.sendSingle( - signedEvent = event, - relayTemplate = - RelaySetupInfoToConnect( - url = nip47.relayUri, - forceProxy = shouldUseTorForTrustedRelays(), // this is trusted. - read = true, - write = true, - feedTypes = wcListener.feedTypes, - ), - onDone = { wcListener.destroy() }, - ) - - onSent() - } - } + val (event, relay) = nip47SignerState.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse) + client.send(event, setOf(relay)) } - fun createZapRequestFor( - userPubKeyHex: String, + suspend fun createZapRequestFor( + user: User, message: String = "", zapType: LnZapEvent.ZapType, - onReady: (LnZapRequestEvent) -> Unit, - ) { - LnZapRequestEvent.create( - userPubKeyHex, - userProfile() - .latestContactList - ?.relays() - ?.keys - ?.ifEmpty { null } - ?: settings.localRelays.map { it.url }.toSet(), - signer, - message, - zapType, - onReady = onReady, - ) + ): LnZapRequestEvent { + val zapRequest = + LnZapRequestEvent.create( + userHex = user.pubkeyHex, + relays = nip65RelayList.inboxFlow.value + user.inboxRelays(), + signer = signer, + message = message, + zapType = zapType, + ) + + cache.justConsumeMyOwnEvent(zapRequest) + return zapRequest } suspend fun report( note: Note, type: ReportType, content: String = "", - ) { - if (!isWriteable()) return - - if (note.hasReport(userProfile(), type)) { - // has already reported this note - return - } - - note.event?.let { - signer.sign(ReportEvent.build(it, type)) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } - } + ) = sendMyPublicAndPrivateOutbox(ReportAction.report(note, type, content, userProfile(), signer)) suspend fun report( user: User, type: ReportType, - ) { + ) = sendMyPublicAndPrivateOutbox(ReportAction.report(user, type, userProfile(), signer)) + + suspend fun delete(note: Note) = delete(listOf(note)) + + suspend fun delete(notes: List) { if (!isWriteable()) return - if (user.hasReport(userProfile(), type)) { - // has already reported this note - return - } - - val template = ReportEvent.build(user.pubkeyHex, type) - signer.sign(template) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } - - fun delete(note: Note) { - delete(listOf(note)) - } - - fun delete(notes: List) { - if (!isWriteable()) return - - val myNoteVersions = notes.filter { it.author == userProfile() }.mapNotNull { it.event as? Event } - if (myNoteVersions.isNotEmpty()) { + val myNotes = notes.filter { it.author == userProfile() && it.event != null } + if (myNotes.isNotEmpty()) { // chunks in 200 elements to avoid going over the 65KB limit for events. - myNoteVersions.chunked(200).forEach { chunkedList -> - signer.sign( - DeletionEvent.build(chunkedList), - ) { deletionEvent -> - Amethyst.instance.client.send(deletionEvent) - LocalCache.justConsume(deletionEvent, null) + myNotes.chunked(200).forEach { chunkedList -> + val template = DeletionEvent.build(chunkedList.mapNotNull { it.event }) + val deletionEvent = signer.sign(template) + val myRelayList = outboxRelays.flow.value.toMutableSet() + chunkedList.forEach { + myRelayList.addAll(it.relays) } + + client.send(deletionEvent, myRelayList) + cache.justConsumeMyOwnEvent(deletionEvent) } } } + suspend fun delete( + event: Event, + additionalRelays: Set, + ) { + if (!isWriteable()) return + if (event.pubKey != signer.pubKey) return + + val deletionEvent = signer.sign(DeletionEvent.build(listOf(event))) + client.send(deletionEvent, outboxRelays.flow.value + additionalRelays) + cache.justConsumeMyOwnEvent(deletionEvent) + } + suspend fun createHTTPAuthorization( url: String, method: String, body: ByteArray? = null, - ): HTTPAuthorizationEvent? { - if (!isWriteable()) return null - - val template = HTTPAuthorizationEvent.build(url, method, body) - - return tryAndWait { continuation -> - signer.sign(template) { - continuation.resume(it) - } - } - } + ): HTTPAuthorizationEvent? = signer.sign(HTTPAuthorizationEvent.build(url, method, body)) suspend fun createBlossomUploadAuth( hash: HexKey, size: Long, alt: String, - ): BlossomAuthorizationEvent? { - if (!isWriteable()) return null - - return tryAndWait { continuation -> - BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer) { - continuation.resume(it) - } - } - } + ) = blossomServers.createBlossomUploadAuth(hash, size, alt) suspend fun createBlossomDeleteAuth( hash: HexKey, alt: String, - ): BlossomAuthorizationEvent? { - if (!isWriteable()) return null + ) = blossomServers.createBlossomDeleteAuth(hash, alt) - return tryAndWait { continuation -> - BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer) { - continuation.resume(it) - } + suspend fun boost(note: Note) { + RepostAction.repost(note, signer)?.let { event -> + client.send(event, computeMyReactionToNote(note, event)) + cache.justConsumeMyOwnEvent(event) } } - suspend fun boost(note: Note) { - if (!isWriteable()) return - val noteEvent = note.event ?: return + fun computeMyReactionToNote( + note: Note, + reaction: Event, + ): Set { + val relaysItCameFrom = note.relays - if (note.hasBoostedInTheLast5Minutes(userProfile())) { - // has already bosted in the past 5mins - return - } + val inboxRelaysOfTheAuthorOfTheOriginalNote = + note.author?.inboxRelays() ?: note.author?.pubkeyHex?.let { + cache.relayHints.hintsForKey(it) + } ?: emptyList() - val noteHint = note.relayHintUrl() - val authorHint = note.author?.bestRelayHint() + val reactionOutBoxRelays = outboxRelays.flow.value - val template = - if (noteEvent.kind == 1) { - RepostEvent.build(noteEvent, noteHint, authorHint) - } else { - GenericRepostEvent.build(noteEvent, noteHint, authorHint) + val taggedUsers = reaction.taggedUserIds() + (note.event?.taggedUserIds() ?: emptyList()) + + val taggedUserInboxRelays = + taggedUsers.flatMapTo(mutableSetOf()) { pubkey -> + if (pubkey == userProfile().pubkeyHex) { + notificationRelays.flow.value + } else { + cache + .getUserIfExists(pubkey) + ?.inboxRelays() + ?.ifEmpty { null } + ?.toSet() + ?: cache.relayHints.hintsForKey(pubkey).toSet() + } } - signer.sign(template) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) + val channelRelays = cache.getAnyChannel(note)?.relays() ?: emptySet() + + val replyRelays = + note.replyTo?.flatMapTo(mutableSetOf()) { + val existingRelays = it.relays.toSet() + + val replyToAuthor = it.author + + val replyAuthorRelays = + if (replyToAuthor != null) { + if (replyToAuthor == userProfile()) { + outboxRelays.flow.value + } else { + replyToAuthor.outboxRelays().ifEmpty { null }?.toSet() + ?: cache.relayHints + .hintsForKey(replyToAuthor.pubkeyHex) + .ifEmpty { null } + ?.toSet() + ?: emptySet() + } + } else { + emptySet() + } + + existingRelays + replyAuthorRelays + } ?: emptySet() + + return reactionOutBoxRelays + + inboxRelaysOfTheAuthorOfTheOriginalNote + + taggedUserInboxRelays + + channelRelays + + replyRelays + + relaysItCameFrom + } + + private fun computeRelayListForLinkedUser(user: User): Set = + if (user == userProfile()) { + notificationRelays.flow.value + } else { + user.inboxRelays().ifEmpty { null }?.toSet() + ?: (cache.relayHints.hintsForKey(user.pubkeyHex).toSet() + user.relaysBeingUsed.keys) + } + + private fun computeRelayListForLinkedUser(pubkey: HexKey): Set = + if (pubkey == userProfile().pubkeyHex) { + notificationRelays.flow.value + } else { + cache + .getUserIfExists(pubkey) + ?.inboxRelays() + ?.ifEmpty { null } + ?.toSet() + ?: cache.relayHints.hintsForKey(pubkey).toSet() + } + + private fun computeRelaysForChannels(event: Event): Set = cache.getAnyChannel(event)?.relays() ?: emptySet() + + fun computeRelayListToBroadcast(event: Event): Set { + if (event is MetadataEvent || event is AdvertisedRelayListEvent) { + return followPlusAllMine.flow.value + client.relayStatusFlow().value.available + } + if (event is GiftWrapEvent) { + val receiver = event.recipientPubKey() + if (receiver != null) { + val relayList = + cache + .getOrCreateUser(receiver) + .dmInboxRelayList() + ?.relays() + ?.ifEmpty { null } + if (relayList != null) { + client.send(event, relayList.toSet()) + } else { + val publicRelayList = computeRelayListForLinkedUser(receiver) + client.send(event, publicRelayList) + } + } else { + return emptySet() + } + } + if (event is WrappedEvent) { + return emptySet() + } + + val relayList = mutableSetOf() + + val author = cache.getUserIfExists(event.pubKey) + + if (author != null) { + if (author == userProfile()) { + relayList.addAll(outboxRelays.flow.value) + } else { + relayList.addAll( + author.outboxRelays().ifEmpty { null } + ?: cache.relayHints.hintsForKey(author.pubkeyHex), + ) + } + } else { + relayList.addAll(cache.relayHints.hintsForKey(event.pubKey)) + } + + if (event is PubKeyHintProvider) { + event.pubKeyHints().forEach { + relayList.add(it.relay) + } + event.linkedPubKeys().forEach { pubkey -> + relayList.addAll(computeRelayListForLinkedUser(pubkey)) + } + } + + if (event is EventHintProvider) { + event.eventHints().forEach { + relayList.add(it.relay) + } + event.linkedEventIds().forEach { eventId -> + cache.getNoteIfExists(eventId)?.let { linkedNote -> + val linkedNoteAuthor = linkedNote.author + + if (linkedNoteAuthor != null) { + relayList.addAll(computeRelayListForLinkedUser(linkedNoteAuthor)) + } else { + relayList.addAll(linkedNote.relays.toSet()) + } + + linkedNote.event?.let { linkedEvent -> + relayList.addAll(computeRelaysForChannels(linkedEvent)) + } + } + } + } + + if (event is AddressHintProvider) { + event.addressHints().forEach { + relayList.add(it.relay) + } + event.linkedAddressIds().forEach { addressId -> + cache.getAddressableNoteIfExists(addressId)?.let { linkedNote -> + val linkedNoteAuthor = linkedNote.author + + if (linkedNoteAuthor != null) { + relayList.addAll(computeRelayListForLinkedUser(linkedNoteAuthor)) + } else { + relayList.addAll(linkedNote.relays.toSet()) + } + + linkedNote.event?.let { linkedEvent -> + relayList.addAll(computeRelaysForChannels(linkedEvent)) + } + } + } + } + + relayList.addAll(computeRelaysForChannels(event)) + + return relayList + } + + fun computeRelayListToBroadcast(note: Note): Set { + val noteEvent = note.event + return if (noteEvent != null) { + computeRelayListToBroadcast(noteEvent) + } else { + note.relays.toSet() } } fun broadcast(note: Note) { note.event?.let { if (it is WrappedEvent && it.host != null) { - it.host?.let { - Amethyst.instance.client.sendFilterAndStopOnFirstResponse( + // download the event and send it. + it.host?.let { host -> + client.downloadFirstEvent( filters = - listOf( - TypedFilter( - setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL), - SincePerRelayFilter( - ids = listOf(it.id), + note.relays.associateWith { relay -> + listOf( + Filter( + ids = listOf(host.id), ), - ), - ), + ) + }, onResponse = { - Amethyst.instance.client.send(it) + client.send(it, computeRelayListToBroadcast(it)) }, ) } } else { - Amethyst.instance.client.send(it) + client.send(it, computeRelayListToBroadcast(note)) } } } - suspend fun updateAttestations() { - Log.d("Pending Attestations", "Updating ${settings.pendingAttestations.value.size} pending attestations") + suspend fun updateAttestations() = sendAutomatic(otsState.updateAttestations()) - val otsResolver = otsResolver() + suspend fun follow(user: User) = sendMyPublicAndPrivateOutbox(kind3FollowList.follow(user)) - settings.pendingAttestations.value.forEach { pair -> - val otsState = OtsEvent.upgrade(Base64.getDecoder().decode(pair.value), pair.key, otsResolver) + suspend fun unfollow(user: User) = sendMyPublicAndPrivateOutbox(kind3FollowList.unfollow(user)) - if (otsState != null) { - val hint = LocalCache.getNoteIfExists(pair.key)?.toEventHint() + suspend fun follow(channel: PublicChatChannel) = sendMyPublicAndPrivateOutbox(publicChatList.follow(channel)) - val template = - if (hint != null) { - OtsEvent.build(hint, otsState) - } else { - OtsEvent.build(pair.key, otsState) - } + suspend fun unfollow(channel: PublicChatChannel) = sendMyPublicAndPrivateOutbox(publicChatList.unfollow(channel)) - signer.sign(template) { - LocalCache.justConsume(it, null) - Amethyst.instance.client.send(it) + suspend fun follow(channel: EphemeralChatChannel) = sendMyPublicAndPrivateOutbox(ephemeralChatList.follow(channel)) - settings.pendingAttestations.update { - it - pair.key - } - } - } + suspend fun unfollow(channel: EphemeralChatChannel) = sendMyPublicAndPrivateOutbox(ephemeralChatList.unfollow(channel)) + + suspend fun follow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.follow(community)) + + suspend fun unfollow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.unfollow(community)) + + suspend fun followHashtag(tag: String) = sendMyPublicAndPrivateOutbox(hashtagList.follow(tag)) + + suspend fun unfollowHashtag(tag: String) = sendMyPublicAndPrivateOutbox(hashtagList.unfollow(tag)) + + suspend fun followGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.follow(geohash)) + + suspend fun unfollowGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.unfollow(geohash)) + + fun sendAutomatic(events: List) = events.forEach { sendAutomatic(it) } + + fun sendAutomatic(event: Event?) { + if (event == null) return + cache.justConsumeMyOwnEvent(event) + client.send(event, computeRelayListToBroadcast(event)) + } + + fun sendMyPublicAndPrivateOutbox(event: Event?) { + if (event == null) return + cache.justConsumeMyOwnEvent(event) + client.send(event, outboxRelays.flow.value) + } + + fun sendMyPublicAndPrivateOutbox(events: List) { + events.forEach { + client.send(it, outboxRelays.flow.value) + cache.justConsumeMyOwnEvent(it) } } - fun hasPendingAttestations(note: Note): Boolean { - val id = note.event?.id ?: note.idHex - return settings.pendingAttestations.value[id] != null + fun sendLiterallyEverywhere(event: Event) { + client.send(event, outboxRelays.flow.value + indexerRelayList.flow.value + client.relayStatusFlow().value.available) + cache.justConsumeMyOwnEvent(event) } - fun timestamp(note: Note) { - if (!isWriteable()) return - if (note.isDraft()) return - - val id = note.event?.id ?: note.idHex - val otsResolver = otsResolver() - - settings.addPendingAttestation(id, Base64.getEncoder().encodeToString(OtsEvent.stamp(id, otsResolver))) - } - - fun follow(user: User) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null) { - ContactListEvent.followUser(contactList, user.pubkeyHex, signer) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } else { - ContactListEvent.createFromScratch( - followUsers = listOf(ContactTag(user.pubkeyHex, user.bestRelayHint(), null)), - followTags = emptyList(), - followGeohashes = emptyList(), - followCommunities = emptyList(), - followEvents = DefaultChannels.toList(), - relayUse = - Constants.defaultRelays.associate { - it.url to ReadWrite(it.read, it.write) - }, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } - } - - fun follow(channel: Channel) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null) { - ContactListEvent.followEvent(contactList, channel.idHex, signer) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } else { - ContactListEvent.createFromScratch( - followUsers = emptyList(), - followTags = emptyList(), - followGeohashes = emptyList(), - followCommunities = emptyList(), - followEvents = DefaultChannels.toList().plus(channel.idHex), - relayUse = - Constants.defaultRelays.associate { - it.url to ReadWrite(it.read, it.write) - }, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } - } - - fun follow(community: AddressableNote) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null) { - ContactListEvent.followAddressableEvent(contactList, community.toATag(), signer) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } else { - val relays = - Constants.defaultRelays.associate { - it.url to ReadWrite(it.read, it.write) - } - ContactListEvent.createFromScratch( - followUsers = emptyList(), - followTags = emptyList(), - followGeohashes = emptyList(), - followCommunities = listOf(community.toATag()), - followEvents = DefaultChannels.toList(), - relayUse = relays, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } - } - - fun followHashtag(tag: String) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null) { - ContactListEvent.followHashtag( - contactList, - tag, - signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } else { - ContactListEvent.createFromScratch( - followUsers = emptyList(), - followTags = listOf(tag), - followGeohashes = emptyList(), - followCommunities = emptyList(), - followEvents = DefaultChannels.toList(), - relayUse = - Constants.defaultRelays.associate { - it.url to ReadWrite(it.read, it.write) - }, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } - } - - fun followGeohash(geohash: String) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null) { - ContactListEvent.followGeohash( - contactList, - geohash, - signer, - onReady = this::onNewEventCreated, - ) - } else { - ContactListEvent.createFromScratch( - followUsers = emptyList(), - followTags = emptyList(), - followGeohashes = listOf(geohash), - followCommunities = emptyList(), - followEvents = DefaultChannels.toList(), - relayUse = - Constants.defaultRelays.associate { - it.url to ReadWrite(it.read, it.write) - }, - signer = signer, - onReady = this::onNewEventCreated, - ) - } - } - - fun onNewEventCreated(event: Event) { - Amethyst.instance.client.send(event) - LocalCache.justConsume(event, null) - } - - fun unfollow(user: User) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null && contactList.tags.isNotEmpty()) { - ContactListEvent.unfollowUser( - contactList, - user.pubkeyHex, - signer, - onReady = this::onNewEventCreated, - ) - } - } - - suspend fun unfollowHashtag(tag: String) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null && contactList.tags.isNotEmpty()) { - ContactListEvent.unfollowHashtag( - contactList, - tag, - signer, - onReady = this::onNewEventCreated, - ) - } - } - - suspend fun unfollowGeohash(geohash: String) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null && contactList.tags.isNotEmpty()) { - ContactListEvent.unfollowGeohash( - contactList, - geohash, - signer, - onReady = this::onNewEventCreated, - ) - } - } - - suspend fun unfollow(channel: Channel) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null && contactList.tags.isNotEmpty()) { - ContactListEvent.unfollowEvent( - contactList, - channel.idHex, - signer, - onReady = this::onNewEventCreated, - ) - } - } - - suspend fun unfollow(community: AddressableNote) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null && contactList.tags.isNotEmpty()) { - ContactListEvent.unfollowAddressableEvent( - contactList, - community.toATag(), - signer, - onReady = this::onNewEventCreated, - ) - } - } - - fun createNip95( + suspend fun createNip95( byteArray: ByteArray, headerInfo: FileHeader, alt: String?, contentWarningReason: String?, - onReady: (Pair) -> Unit, - ) { - if (!isWriteable()) return + ): Pair { + val data = signer.sign(FileStorageEvent.build(byteArray, headerInfo.mimeType)) - signer.sign(FileStorageEvent.build(byteArray, headerInfo.mimeType)) { data -> - val template = - FileStorageHeaderEvent.build(EventHintBundle(data, userProfile().bestRelayHint()), alt) { - hash(headerInfo.hash) - fileSize(headerInfo.size) - - headerInfo.mimeType?.let { mimeType(it) } - headerInfo.dim?.let { dimension(it) } - headerInfo.blurHash?.let { blurhash(it.blurhash) } - - contentWarningReason?.let { contentWarning(contentWarningReason) } - } - - signer.sign(template) { signedEvent -> - onReady( - Pair(data, signedEvent), - ) - } - } - } - - fun consumeAndSendNip95( - data: FileStorageEvent, - signedEvent: FileStorageHeaderEvent, - relayList: List, - ): Note? { - if (!isWriteable()) return null - - Amethyst.instance.client.send(data, relayList = relayList) - LocalCache.consume(data, null) - - Amethyst.instance.client.send(signedEvent, relayList = relayList) - LocalCache.consume(signedEvent, null) - - return LocalCache.getNoteIfExists(signedEvent.id) - } - - fun consumeNip95( - data: FileStorageEvent, - signedEvent: FileStorageHeaderEvent, - ): Note? { - LocalCache.consume(data, null) - LocalCache.consume(signedEvent, null) - - return LocalCache.getNoteIfExists(signedEvent.id) - } - - fun sendNip95( - data: FileStorageEvent, - signedEvent: FileStorageHeaderEvent, - relayList: List, - ) { - Amethyst.instance.client.send(data, relayList = relayList) - Amethyst.instance.client.send(signedEvent, relayList = relayList) - } - - fun sendNip95Privately( - data: FileStorageEvent, - signedEvent: FileStorageHeaderEvent, - relayList: List, - ) { - val connect = - relayList.map { - val normalizedUrl = RelayUrlFormatter.normalize(it) - RelaySetupInfoToConnect( - normalizedUrl, - shouldUseTorForClean(normalizedUrl), - true, - true, - setOf(FeedType.GLOBAL), - ) - } - - Amethyst.instance.client.sendPrivately(data, relayList = connect) - Amethyst.instance.client.sendPrivately(signedEvent, relayList = connect) - } - - fun sendHeader( - signedEvent: Event, - relayList: List, - onReady: (Note) -> Unit, - ) { - Amethyst.instance.client.send(signedEvent, relayList = relayList) - LocalCache.justConsume(signedEvent, null) - - LocalCache.getNoteIfExists(signedEvent.id)?.let { onReady(it) } - } - - fun createHeader( - imageUrl: String, - magnetUri: String?, - headerInfo: FileHeader, - alt: String?, - contentWarningReason: String? = null, - originalHash: String? = null, - onReady: (FileHeaderEvent) -> Unit, - ) { - if (!isWriteable()) return - - signer.sign( - FileHeaderEvent.build(imageUrl, alt) { + val template = + FileStorageHeaderEvent.build(EventHintBundle(data, userProfile().bestRelayHint()), alt) { hash(headerInfo.hash) fileSize(headerInfo.size) @@ -2160,21 +908,85 @@ class Account( headerInfo.dim?.let { dimension(it) } headerInfo.blurHash?.let { blurhash(it.blurhash) } - originalHash?.let { originalHash(it) } - magnetUri?.let { magnet(it) } - contentWarningReason?.let { contentWarning(contentWarningReason) } - }, - onReady, - ) + } + + val signedEvent = signer.sign(template) + return Pair(data, signedEvent) } - fun sendAllAsOnePictureEvent( + fun consumeAndSendNip95( + data: FileStorageEvent, + signedEvent: FileStorageHeaderEvent, + ): Note? { + if (!isWriteable()) return null + + val relayList = computeRelayListToBroadcast(signedEvent) + + client.send(data, relayList = relayList) + cache.justConsumeMyOwnEvent(data) + + client.send(signedEvent, relayList = relayList) + cache.justConsumeMyOwnEvent(signedEvent) + + return cache.getNoteIfExists(signedEvent.id) + } + + fun consumeNip95( + data: FileStorageEvent, + signedEvent: FileStorageHeaderEvent, + ): Note? { + cache.justConsumeMyOwnEvent(data) + cache.justConsumeMyOwnEvent(signedEvent) + + return cache.getNoteIfExists(signedEvent.id) + } + + fun sendNip95( + data: FileStorageEvent, + signedEvent: FileStorageHeaderEvent, + relayList: Set, + ) { + client.send(data, relayList = relayList) + client.send(signedEvent, relayList = relayList) + } + + fun sendHeader( + signedEvent: Event, + relayList: Set, + onReady: (Note) -> Unit, + ) { + client.send(signedEvent, relayList = relayList) + cache.justConsumeMyOwnEvent(signedEvent) + + cache.getNoteIfExists(signedEvent.id)?.let { onReady(it) } + } + + suspend fun sendVoiceMessage( + url: String, + mimeType: String?, + hash: String, + duration: Int, + waveform: List, + ) { + signAndComputeBroadcast(VoiceEvent.build(url, mimeType, hash, duration, waveform)) + } + + suspend fun sendVoiceReplyMessage( + url: String, + mimeType: String?, + hash: String, + duration: Int, + waveform: List, + replyTo: EventHintBundle, + ) { + signAndComputeBroadcast(VoiceReplyEvent.build(url, mimeType, hash, duration, waveform, replyTo)) + } + + suspend fun sendAllAsOnePictureEvent( urlHeaderInfo: Map, caption: String?, contentWarningReason: String?, - relayList: List, - onReady: (Note) -> Unit, ) { val iMetas = urlHeaderInfo.map { @@ -2192,7 +1004,7 @@ class Account( ) } - signer.sign( + val template = PictureEvent.build(iMetas, caption ?: "") { caption?.let { hashtags(findHashtags(it)) @@ -2204,21 +1016,18 @@ class Account( // add geohashes // add title contentWarningReason?.let { contentWarning(contentWarningReason) } - }, - ) { - sendHeader(it, relayList = relayList, onReady) - } + } + + signAndComputeBroadcast(template) } - fun sendHeader( + suspend fun sendHeader( url: String, magnetUri: String?, headerInfo: FileHeader, alt: String?, contentWarningReason: String?, originalHash: String? = null, - relayList: List, - onReady: (Note) -> Unit, ) { if (!isWriteable()) return @@ -2285,193 +1094,163 @@ class Account( } } - signer.sign(template) { - sendHeader(it, relayList = relayList, onReady) + signAndComputeBroadcast(template) + } + + suspend fun signAndSendPrivately( + template: EventTemplate, + relayList: Set, + ) { + val event = signer.sign(template) + cache.justConsumeMyOwnEvent(event) + client.send(event, relayList) + } + + suspend fun signAndSendPrivatelyOrBroadcast( + template: EventTemplate, + relayList: (T) -> List?, + ): T { + val event = signer.sign(template) + cache.justConsumeMyOwnEvent(event) + val relays = relayList(event) + if (relays != null && relays.isNotEmpty()) { + client.send(event, relays.toSet()) + } else { + client.send(event, computeRelayListToBroadcast(event)) + } + return event + } + + suspend fun signAndComputeBroadcast( + template: EventTemplate, + broadcast: List = emptyList(), + ): T { + val event = signer.sign(template) + cache.justConsumeMyOwnEvent(event) + val note = + if (event is AddressableEvent) { + cache.getOrCreateAddressableNote(event.address()) + } else { + cache.getOrCreateNote(event.id) + } + + val relayList = computeRelayListToBroadcast(note) + + client.send(event, relayList) + + broadcast.forEach { client.send(it, relayList) } + + return event + } + + suspend fun signAndSend( + draftTag: String?, + template: EventTemplate, + relayList: Set, + broadcastNotes: List, + ) = signAndSend(draftTag, template, relayList, mapEntitiesToNotes(broadcastNotes).toSet()) + + suspend fun signAndSend( + draftTag: String?, + template: EventTemplate, + relayList: Set, + broadcastNotes: Set, + ) { + if (draftTag != null) { + val rumor = RumorAssembler.assembleRumor(signer.pubKey, template) + val draftEvent = + DraftEvent.create( + dTag = draftTag, + innerEvent = rumor, + anchorTagArray = emptyList(), + signer = signer, + ) + draftsDecryptionCache.preload(draftEvent, rumor) + sendDraftEvent(draftEvent) + } else { + val it = signer.sign(template) + cache.justConsumeMyOwnEvent(it) + client.send(it, relayList = relayList) + + broadcastNotes.forEach { it.event?.let { client.send(it, relayList = relayList) } } } } - fun signAndSend( + suspend fun signAndSendNIP04Message( draftTag: String?, template: EventTemplate, + relayList: Set, ) { if (draftTag != null) { if (template.content.isEmpty()) { deleteDraft(draftTag) } else { - signer.assembleRumor(template) { rumor -> - DraftEvent.create(draftTag, rumor, emptyList(), signer) { draftEvent -> - sendDraftEvent(draftEvent) - } - } + val rumor = RumorAssembler.assembleRumor(signer.pubKey, template) + val draftEvent = DraftEvent.create(draftTag, rumor, emptyList(), signer) + draftsDecryptionCache.preload(draftEvent, rumor) + sendDraftEvent(draftEvent) } } else { - signer.sign(template) { - LocalCache.justConsume(it, null) - Amethyst.instance.client.send(it) - } + val newEvent = signer.sign(template) + cache.justConsumeMyOwnEvent(newEvent) + client.send(newEvent, relayList) } } - fun signAndSendPrivately( - template: EventTemplate, - relayList: List, - onDone: (T) -> Unit = {}, - ) { - signer.sign(template) { - LocalCache.justConsume(it, null) - Amethyst.instance.client.sendPrivately(it, relayList = convertRelayList(relayList)) - onDone(it) - } - } - - fun signAndSendPrivatelyOrBroadcast( - template: EventTemplate, - relayList: (T) -> List?, - onDone: (T) -> Unit = {}, - ) { - signer.sign(template) { - LocalCache.justConsume(it, null) - val relays = relayList(it) - if (relays != null) { - Amethyst.instance.client.sendPrivately(it, relayList = convertRelayList(relays)) - } else { - Amethyst.instance.client.send(it) - } - onDone(it) - } - } - - fun signAndSend( - template: EventTemplate, - relayList: List, - broadcastNotes: Set, - ) { - signer.sign(template) { - LocalCache.justConsume(it, null) - Amethyst.instance.client.send(it, relayList = relayList) - - broadcastNotes.forEach { it.event?.let { Amethyst.instance.client.send(it, relayList = relayList) } } - } - } - - fun signAndSend( + suspend fun signAndSendWithList( draftTag: String?, template: EventTemplate, - relayList: List, - broadcastNotes: List, - ) = signAndSend(draftTag, template, relayList, mapEntitiesToNotes(broadcastNotes).toSet()) - - fun signAndSend( - draftTag: String?, - template: EventTemplate, - relayList: List, + relayList: Collection, broadcastNotes: Set, ) { if (draftTag != null) { - signer.assembleRumor(template) { rumor -> - DraftEvent.create(draftTag, rumor, emptyList(), signer) { draftEvent -> - sendDraftEvent(draftEvent) - } - } + val rumor = RumorAssembler.assembleRumor(signer.pubKey, template) + val draftEvent = DraftEvent.create(draftTag, rumor, emptyList(), signer) + draftsDecryptionCache.preload(draftEvent, rumor) + sendDraftEvent(draftEvent) } else { - signer.sign(template) { - LocalCache.justConsume(it, null) - Amethyst.instance.client.send(it, relayList = relayList) + val event = signer.sign(template) + cache.justConsumeMyOwnEvent(event) - broadcastNotes.forEach { it.event?.let { Amethyst.instance.client.send(it, relayList = relayList) } } - } + val relaySet = relayList.toSet() + + client.send(event, relayList = relaySet) + broadcastNotes.forEach { it.event?.let { client.send(it, relayList = relaySet) } } } } - fun signAndSendWithList( - draftTag: String?, - template: EventTemplate, - relayList: List, - broadcastNotes: Set, - ) { - if (draftTag != null) { - signer.assembleRumor(template) { rumor -> - DraftEvent.create(draftTag, rumor, emptyList(), signer) { draftEvent -> - sendDraftEvent(draftEvent) - } - } - } else { - signer.sign(template) { - val connect = - relayList.map { - val normalizedUrl = RelayUrlFormatter.normalize(it) - RelaySetupInfoToConnect( - normalizedUrl, - shouldUseTorForClean(normalizedUrl), - true, - true, - setOf(FeedType.GLOBAL), - ) - } - - LocalCache.justConsume(it, null) - Amethyst.instance.client.sendPrivately(it, relayList = connect) - broadcastNotes.forEach { it.event?.let { Amethyst.instance.client.sendPrivately(it, relayList = connect) } } - } - } - } - - fun sendTorrentComment( - draftTag: String?, - template: EventTemplate, - broadcastNotes: Set, - relayList: List, - ) { - if (!isWriteable()) return - - signAndSend(draftTag, template, relayList, broadcastNotes) - } - - fun createAndSendDraft( + suspend fun createAndSendDraft( draftTag: String, template: EventTemplate, ) { - val rumor = signer.assembleRumor(template) - DraftBuilder.encryptAndSign(draftTag, rumor, signer) { draftEvent -> - sendDraftEvent(draftEvent) - } + val rumor = RumorAssembler.assembleRumor(signer.pubKey, template) + val draftEvent = DraftBuilder.encryptAndSign(draftTag, rumor, signer) + draftsDecryptionCache.preload(draftEvent, rumor) + sendDraftEvent(draftEvent) } - fun deleteDraft(draftTag: String) { + suspend fun deleteDraft(draftTag: String) { val key = DraftEvent.createAddressTag(userProfile().pubkeyHex, draftTag) - LocalCache.getAddressableNoteIfExists(key)?.let { note -> + cache.getAddressableNoteIfExists(key)?.let { note -> val noteEvent = note.event if (noteEvent is DraftEvent) { - noteEvent.createDeletedEvent(signer) { - Amethyst.instance.client.sendPrivately( - it, - note.relays.map { it.url }.map { - RelaySetupInfoToConnect( - it, - shouldUseTorForClean(it), - false, - true, - emptySet(), - ) - }, - ) - LocalCache.justConsume(it, null) - } + val deletedDraftEvent = noteEvent.createDeletedEvent(signer) + client.send(deletedDraftEvent, outboxRelays.flow.value + note.relays) + cache.justConsumeMyOwnEvent(deletedDraftEvent) + + delete(deletedDraftEvent, note.relays.toSet()) } - delete(note) } } suspend fun createInteractiveStoryReadingState( root: InteractiveStoryBaseEvent, - rootRelay: String?, + rootRelay: NormalizedRelayUrl?, readingScene: InteractiveStoryBaseEvent, - readingSceneRelay: String?, + readingSceneRelay: NormalizedRelayUrl?, ) { if (!isWriteable()) return - val relayList = getPrivateOutBoxRelayList() - val template = InteractiveStoryReadingStateEvent.build( root = root, @@ -2480,25 +1259,16 @@ class Account( currentSceneRelay = readingSceneRelay, ) - signer.sign(template) { - if (relayList.isNotEmpty()) { - Amethyst.instance.client.sendPrivately(it, relayList = relayList) - } else { - Amethyst.instance.client.send(it) - } - LocalCache.justConsume(it, null) - } + sendToPrivateOutboxAndLocal(signer.sign(template)) } suspend fun updateInteractiveStoryReadingState( readingState: InteractiveStoryReadingStateEvent, readingScene: InteractiveStoryBaseEvent, - readingSceneRelay: String?, + readingSceneRelay: NormalizedRelayUrl?, ) { if (!isWriteable()) return - val relayList = getPrivateOutBoxRelayList() - val template = InteractiveStoryReadingStateEvent.update( base = readingState, @@ -2506,14 +1276,7 @@ class Account( currentSceneRelay = readingSceneRelay, ) - signer.sign(template) { - if (relayList.isNotEmpty()) { - Amethyst.instance.client.sendPrivately(it, relayList = relayList) - } else { - Amethyst.instance.client.send(it) - } - LocalCache.justConsume(it, null) - } + sendToPrivateOutboxAndLocal(signer.sign(template)) } fun mapEntitiesToNotes(entities: List): List = @@ -2521,10 +1284,10 @@ class Account( when (it) { is NPub -> null is NProfile -> null - is com.vitorpamplona.quartz.nip19Bech32.entities.Note -> LocalCache.getOrCreateNote(it.hex) - is NEvent -> LocalCache.getOrCreateNote(it.hex) - is NEmbed -> LocalCache.getOrCreateNote(it.event.id) - is NAddress -> LocalCache.checkGetOrCreateAddressableNote(it.aTag()) + is NNote -> cache.getOrCreateNote(it.hex) + is NEvent -> cache.getOrCreateNote(it.hex) + is NEmbed -> cache.getOrCreateNote(it.event.id) + is NAddress -> cache.checkGetOrCreateAddressableNote(it.aTag()) is NSec -> null is NRelay -> null else -> null @@ -2543,7 +1306,7 @@ class Account( zapRaiserAmount: Long? = null, imetas: List? = null, draftTag: String? = null, - relayList: List, + relayList: Set, ) { if (!isWriteable()) return @@ -2580,7 +1343,7 @@ class Account( zapRaiserAmount: Long? = null, imetas: List? = null, draftTag: String? = null, - relayList: List, + relayList: Set, ) { if (!isWriteable()) return @@ -2609,7 +1372,6 @@ class Account( value: BigDecimal, bounty: Note, draftTag: String?, - relayList: List, ) { if (!isWriteable()) return @@ -2623,59 +1385,41 @@ class Account( eventAuthor.toPTag(), ) - signAndSend(draftTag, template, relayList, setOf(bounty)) + val relays = bounty.relays + outboxRelays.flow.value + + signAndSendWithList(draftTag, template, relays, setOf(bounty)) } - fun sendEdit( + suspend fun sendEdit( message: String, originalNote: Note, notify: HexKey?, summary: String? = null, - relayList: List, + broadcast: List, ) { if (!isWriteable()) return val idHex = originalNote.event?.id ?: return - TextNoteModificationEvent.create( - content = message, - eventId = idHex, - notify = notify, - summary = summary, - signer = signer, - ) { - LocalCache.justConsume(it, null) - Amethyst.instance.client.send(it, relayList = relayList) - } + val event = + TextNoteModificationEvent.create( + content = message, + eventId = idHex, + notify = notify, + summary = summary, + signer = signer, + ) + + cache.justConsumeMyOwnEvent(event) + val note = cache.getOrCreateNote(event.id) + val relayList = computeRelayListToBroadcast(note) + + client.send(event, relayList = relayList) + + broadcast.forEach { client.send(it, relayList) } } - fun sendPrivateMessage( - message: String, - toUser: User, - replyingTo: Note? = null, - zapReceiver: List? = null, - contentWarningReason: String? = null, - zapRaiserAmount: Long? = null, - geohash: String? = null, - imetas: List? = null, - emojis: List? = null, - draftTag: String?, - ) { - sendPrivateMessage( - message, - toUser.toPTag(), - replyingTo, - zapReceiver, - contentWarningReason, - zapRaiserAmount, - geohash, - imetas, - emojis, - draftTag, - ) - } - - fun sendPrivateMessage( + suspend fun sendPrivateMessage( message: String, toUser: PTag, replyingTo: Note? = null, @@ -2689,379 +1433,182 @@ class Account( ) { if (!isWriteable()) return - signer.nip04Encrypt( - PrivateDmEvent.prepareMessageToEncrypt(message, imetas), - toUser.pubKey, - ) { encryptedContent -> - val template = - PrivateDmEvent.build(toUser, encryptedContent) { - replyingTo?.let { reply(it.toEId()) } + val encryptedContent = + signer.nip04Encrypt( + PrivateDmEvent.prepareMessageToEncrypt(message, imetas), + toUser.pubKey, + ) - geohash?.let { geohash(it) } - zapRaiserAmount?.let { zapraiser(it) } - zapReceiver?.let { zapSplits(it) } - emojis?.let { emojis(it) } - contentWarningReason?.let { contentWarning(contentWarningReason) } - } + val template = + PrivateDmEvent.build(toUser, encryptedContent) { + replyingTo?.let { reply(it.toEId()) } - signAndSend(draftTag, template) - } + geohash?.let { geohash(it) } + zapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + emojis?.let { emojis(it) } + contentWarningReason?.let { contentWarning(contentWarningReason) } + } + + val destinationRelays = cache.getOrCreateUser(toUser.pubKey).dmInboxRelays() + + signAndSendNIP04Message(draftTag, template, outboxRelays.flow.value + destinationRelays) } - fun sendNIP17EncryptedFile(template: EventTemplate) { + suspend fun sendNIP17EncryptedFile(template: EventTemplate) { if (!isWriteable()) return - NIP17Factory().createEncryptedFileNIP17(template, signer) { - broadcastPrivately(it) - } + val wraps = NIP17Factory().createEncryptedFileNIP17(template, signer) + broadcastPrivately(wraps) } - fun sendNIP17PrivateMessage( + suspend fun sendNIP17PrivateMessage( template: EventTemplate, draftTag: String? = null, ) { - if (!isWriteable()) return - if (draftTag != null) { if (template.content.isEmpty()) { deleteDraft(draftTag) } else { - signer.assembleRumor(template) { - DraftEvent.create(draftTag, it, emptyList(), signer) { draftEvent -> - sendDraftEvent(draftEvent) - } - } + val rumor = RumorAssembler.assembleRumor(signer.pubKey, template) + val draftEvent = DraftEvent.create(draftTag, rumor, emptyList(), signer) + draftsDecryptionCache.preload(draftEvent, rumor) + sendDraftEvent(draftEvent) } } else { - NIP17Factory().createMessageNIP17(template, signer) { - broadcastPrivately(it) - } + val it = NIP17Factory().createMessageNIP17(template, signer) + broadcastPrivately(it) } } - fun getPrivateOutBoxRelayList(): List = - normalizedPrivateOutBoxRelaySet.value.map { - RelaySetupInfoToConnect( - it, - shouldUseTorForClean(it), - true, - true, - emptySet(), - ) - } - fun sendDraftEvent(draftEvent: DraftEvent) { - val relayList = getPrivateOutBoxRelayList() - if (relayList.isNotEmpty()) { - Amethyst.instance.client.sendPrivately(draftEvent, relayList) - } else { - Amethyst.instance.client.send(draftEvent) - } - LocalCache.justConsume(draftEvent, null) + sendToPrivateOutboxAndLocal(draftEvent) } - fun convertRelayList(broadcast: List): List = - broadcast.map { - val normalizedUrl = RelayUrlFormatter.normalize(it) - RelaySetupInfoToConnect( - normalizedUrl, - shouldUseTorForClean(normalizedUrl), - true, - true, - setOf(FeedType.GLOBAL), - ) - } + fun sendToPrivateOutboxAndLocal(event: Event?) { + if (event == null) return - fun broadcastPrivately(signedEvents: NIP17Factory.Result) { + val relayList = privateStorageRelayList.flow.value + localRelayList.flow.value + if (relayList.isNotEmpty()) { + client.send(event, relayList.toSet()) + } else { + client.send(event, outboxRelays.flow.value) + } + cache.justConsumeMyOwnEvent(event) + } + + suspend fun broadcastPrivately(signedEvents: NIP17Factory.Result) { val mine = signedEvents.wraps.filter { (it.recipientPubKey() == signer.pubKey) } mine.forEach { giftWrap -> - giftWrap.unwrap(signer) { gift -> - if (gift is SealedRumorEvent) { - gift.unseal(signer) { rumor -> - LocalCache.justConsume(rumor, null) - } + val gift = giftWrap.unwrapOrNull(signer) + if (gift is SealedRumorEvent) { + val rumor = gift.unsealOrNull(signer) + if (rumor != null) { + cache.justConsumeMyOwnEvent(rumor) } - - LocalCache.justConsume(gift, null) } - LocalCache.consume(giftWrap, null) + if (gift != null) { + cache.justConsumeMyOwnEvent(gift) + } + + cache.justConsumeMyOwnEvent(giftWrap) } val id = mine.firstOrNull()?.id - val mineNote = if (id == null) null else LocalCache.getNoteIfExists(id) + val mineNote = if (id == null) null else cache.getNoteIfExists(id) signedEvents.wraps.forEach { wrap -> // Creates an alias if (mineNote != null && wrap.recipientPubKey() != signer.pubKey) { - LocalCache.getOrAddAliasNote(wrap.id, mineNote) + cache.getOrAddAliasNote(wrap.id, mineNote) } - val receiver = wrap.recipientPubKey() - if (receiver != null) { - val relayList = - ( - LocalCache - .getAddressableNoteIfExists(ChatMessageRelayListEvent.createAddressTag(receiver)) - ?.event as? ChatMessageRelayListEvent - )?.relays()?.ifEmpty { null }?.map { - val normalizedUrl = RelayUrlFormatter.normalize(it) - RelaySetupInfoToConnect( - normalizedUrl, - shouldUseTorForClean(normalizedUrl), - false, - true, - feedTypes = setOf(FeedType.PRIVATE_DMS), - ) - } - - if (relayList != null) { - Amethyst.instance.client.sendPrivately(signedEvent = wrap, relayList = relayList) - } else { - Amethyst.instance.client.send(wrap) - } - } else { - Amethyst.instance.client.send(wrap) - } + val relayList = computeRelayListToBroadcast(wrap) + client.send(wrap, relayList) } } - fun updateStatus( + suspend fun createStatus(newStatus: String) = sendMyPublicAndPrivateOutbox(UserStatusAction.create(newStatus, signer)) + + suspend fun updateStatus( oldStatus: AddressableNote, newStatus: String, - ) { - if (!isWriteable()) return - val oldEvent = oldStatus.event as? StatusEvent ?: return + ) = sendMyPublicAndPrivateOutbox(UserStatusAction.update(oldStatus, newStatus, signer)) - StatusEvent.update(oldEvent, newStatus, signer) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } + suspend fun deleteStatus(oldStatus: AddressableNote) = sendMyPublicAndPrivateOutbox(UserStatusAction.delete(oldStatus, signer)) - fun createStatus(newStatus: String) { - if (!isWriteable()) return + suspend fun removeEmojiPack(emojiPack: Note) = sendMyPublicAndPrivateOutbox(emoji.removeEmojiPack(emojiPack)) - StatusEvent.create(newStatus, "general", expiration = null, signer) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } + suspend fun addEmojiPack(emojiPack: Note) = sendMyPublicAndPrivateOutbox(emoji.addEmojiPack(emojiPack)) - fun deleteStatus(oldStatus: AddressableNote) { - if (!isWriteable()) return - val oldEvent = oldStatus.event as? StatusEvent ?: return - - StatusEvent.clear(oldEvent, signer) { event -> - Amethyst.instance.client.send(event) - LocalCache.justConsume(event, null) - - signer.sign( - DeletionEvent.buildForVersionOnly(listOf(event)), - ) { event2 -> - Amethyst.instance.client.send(event2) - LocalCache.justConsume(event2, null) - } - } - } - - fun removeEmojiPack( - usersEmojiList: Note, - emojiPack: Note, - ) { - if (!isWriteable()) return - - val noteEvent = usersEmojiList.event - if (noteEvent !is EmojiPackSelectionEvent) return - val emojiPackEvent = emojiPack.event - if (emojiPackEvent !is EmojiPackEvent) return - - signer.sign(EmojiPackSelectionEvent.remove(noteEvent, emojiPackEvent)) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } - - fun addEmojiPack( - usersEmojiList: Note, - emojiPack: Note, - ) { - if (!isWriteable()) return - val emojiPackEvent = emojiPack.event - if (emojiPackEvent !is EmojiPackEvent) return - - val eventHint = emojiPack.toEventHint() ?: return - - if (usersEmojiList.event == null) { - signer.sign(EmojiPackSelectionEvent.build(listOf(eventHint))) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } else { - val noteEvent = usersEmojiList.event - if (noteEvent !is EmojiPackSelectionEvent) return - - signer.sign(EmojiPackSelectionEvent.add(noteEvent, eventHint)) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } - } - - fun addToGallery( + suspend fun addToGallery( idHex: HexKey, url: String, - relay: String?, + relay: NormalizedRelayUrl?, blurhash: String?, dim: DimensionTag?, hash: String?, mimeType: String?, ) { - if (!isWriteable()) return - - signer.sign( + val template = ProfileGalleryEntryEvent.build(url) { fromEvent(idHex, relay) hash?.let { hash(hash) } mimeType?.let { mimeType(it) } dim?.let { dimension(it) } blurhash?.let { blurhash(it) } - }, - ) { - Amethyst.instance.client.send(it) - LocalCache.consume(it, null) - } + } + + val event = signer.sign(template) + sendMyPublicAndPrivateOutbox(event) } - fun removeFromGallery(note: Note) { + suspend fun removeFromGallery(note: Note) { delete(note) } - fun addBookmark( + suspend fun addBookmark( note: Note, isPrivate: Boolean, ) { - if (!isWriteable()) return - if (note.isDraft()) return + if (!isWriteable() || note.isDraft()) return - if (note is AddressableNote) { - BookmarkListEvent.addReplaceable( - userProfile().latestBookmarkList, - note.toATag(), - isPrivate, - signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.consume(it) - } - } else { - BookmarkListEvent.addEvent( - userProfile().latestBookmarkList, - note.idHex, - isPrivate, - signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.consume(it) - } - } + sendMyPublicAndPrivateOutbox(bookmarkState.addBookmark(note, isPrivate)) } - fun removeBookmark( + suspend fun removeBookmark( note: Note, isPrivate: Boolean, ) { - if (!isWriteable()) return + if (!isWriteable() || note.isDraft()) return - val bookmarks = userProfile().latestBookmarkList ?: return - - if (note is AddressableNote) { - BookmarkListEvent.removeReplaceable( - bookmarks, - note.toATag(), - isPrivate, - signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.consume(it) - } - } else { - BookmarkListEvent.removeEvent( - bookmarks, - note.idHex, - isPrivate, - signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.consume(it) - } + val event = bookmarkState.removeBookmark(note, isPrivate) + if (event != null) { + sendMyPublicAndPrivateOutbox(event) } } - fun sendAuthEvent( - relay: Relay, + suspend fun sendAuthEvent( + relay: IRelayClient, challenge: String, ) { - createAuthEvent(relay.url, challenge) { - Amethyst.instance.client.sendIfExists(it, relay) - } + val auth = RelayAuthEvent.create(relay.url, challenge, signer) + client.sendIfExists(auth, relay.url) } - fun createAuthEvent( - relayUrl: String, - challenge: String, - onReady: (RelayAuthEvent) -> Unit, - ) { - if (!isWriteable()) return - - RelayAuthEvent.create(relayUrl, challenge, signer, onReady = onReady) + suspend fun hideWord(word: String) { + sendMyPublicAndPrivateOutbox(muteList.hideWord(word)) } - fun createAuthEvent( - relayUrls: List, - challenge: String, - onReady: (RelayAuthEvent) -> Unit, - ) { - if (!isWriteable()) return - - RelayAuthEvent.create(relayUrls, challenge, signer, onReady = onReady) - } - - fun isInPrivateBookmarks( - note: Note, - onReady: (Boolean) -> Unit, - ) { - if (!isWriteable()) { - onReady(false) - false - } - if (userProfile().latestBookmarkList == null) { - onReady(false) - false - } - - if (note is AddressableNote) { - userProfile().latestBookmarkList?.privateAddress(signer) { - onReady(it.contains(note.address)) - } - } else { - userProfile().latestBookmarkList?.privateTaggedEvents(signer) { - onReady(it.contains(note.idHex)) - } - } - } - - fun isInPublicBookmarks(note: Note): Boolean { - if (!isWriteable()) return false - - if (note is AddressableNote) { - return userProfile().latestBookmarkList?.isTaggedAddressableNote(note.idHex) == true - } else { - return userProfile().latestBookmarkList?.isTaggedEvent(note.idHex) == true - } + suspend fun showWord(word: String) { + sendMyPublicAndPrivateOutbox(blockPeopleList.showWord(word)) + sendMyPublicAndPrivateOutbox(muteList.showWord(word)) } + suspend fun hideUser(pubkeyHex: HexKey) { + sendMyPublicAndPrivateOutbox(muteList.hideUser(pubkeyHex)) fun getAppSpecificDataNote() = LocalCache.getOrCreateAddressableNote(AppSpecificDataEvent.createAddress(userProfile().pubkeyHex, APP_SPECIFIC_DATA_D_TAG)) fun getAppSpecificDataFlow(): StateFlow = getAppSpecificDataNote().flow().metadata.stateFlow @@ -3118,144 +1665,30 @@ class Account( } } - fun showWord(word: String) { - val blockList = getBlockList() - - if (blockList != null) { - PeopleListEvent.removeWord( - earlierVersion = blockList, - word = word, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.consume(it, null) - } - } - - val muteList = getMuteList() - - if (muteList != null) { - MuteListEvent.removeWord( - earlierVersion = muteList, - word = word, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.consume(it, null) - } - } + suspend fun showUser(pubkeyHex: HexKey) { + sendMyPublicAndPrivateOutbox(blockPeopleList.showUser(pubkeyHex)) + sendMyPublicAndPrivateOutbox(muteList.showUser(pubkeyHex)) + hiddenUsers.showUser(pubkeyHex) } - fun hideUser(pubkeyHex: String) { - val muteList = getMuteList() - - if (muteList != null) { - MuteListEvent.addUser( - earlierVersion = muteList, - pubKeyHex = pubkeyHex, - isPrivate = true, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.consume(it, null) - } - } else { - MuteListEvent.createListWithUser( - pubKeyHex = pubkeyHex, - isPrivate = true, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.consume(it, null) - } - } - } - - fun showUser(pubkeyHex: String) { - val blockList = getBlockList() - - if (blockList != null) { - PeopleListEvent.removeUser( - earlierVersion = blockList, - pubKeyHex = pubkeyHex, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.consume(it, null) - } - } - - val muteList = getMuteList() - - if (muteList != null) { - MuteListEvent.removeUser( - earlierVersion = muteList, - pubKeyHex = pubkeyHex, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.consume(it, null) - } - } - - transientHiddenUsers.update { - it - pubkeyHex - } - } - - fun selectedChatsFollowList(): Set { - val contactList = userProfile().latestContactList - return contactList?.taggedEventIds()?.toSet() ?: DefaultChannels - } - - fun requestDVMContentDiscovery( - dvmPublicKey: String, + suspend fun requestDVMContentDiscovery( + dvmPublicKey: User, onReady: (event: NIP90ContentDiscoveryRequestEvent) -> Unit, ) { - NIP90ContentDiscoveryRequestEvent.create(dvmPublicKey, signer.pubKey, getReceivingRelays(), signer) { - val relayList = - ( - LocalCache - .getAddressableNoteIfExists( - AdvertisedRelayListEvent.createAddressTag(dvmPublicKey), - )?.event as? AdvertisedRelayListEvent - )?.readRelays()?.ifEmpty { null }?.map { - val normalizedUrl = RelayUrlFormatter.normalize(it) - RelaySetupInfoToConnect( - normalizedUrl, - shouldUseTorForClean(normalizedUrl), - true, - true, - setOf(FeedType.GLOBAL), - ) - } + val relays = nip65RelayList.inboxFlow.value.toSet() + val request = NIP90ContentDiscoveryRequestEvent.create(dvmPublicKey.pubkeyHex, signer.pubKey, relays, signer) - if (relayList != null) { - Amethyst.instance.client.sendPrivately(it, relayList) - } else { - Amethyst.instance.client.send(it) - } - LocalCache.justConsume(it, null) - onReady(it) - } - } + val relayList = + dvmPublicKey + .inboxRelays() + .ifEmpty { + cache.relayHints.hintsForKey(dvmPublicKey.pubkeyHex) + dvmPublicKey.relaysBeingUsed.keys + }.toSet() - fun unwrap( - event: GiftWrapEvent, - onReady: (Event) -> Unit, - ) { - if (!isWriteable()) return - - return event.unwrap(signer, onReady) - } - - fun unseal( - event: SealedRumorEvent, - onReady: (Event) -> Unit, - ) { - if (!isWriteable()) return - - return event.unseal(signer, onReady) + cache.justConsumeMyOwnEvent(request) + onReady(request) + delay(100) + client.send(request, relayList) } fun cachedDecryptContent(note: Note): String? = cachedDecryptContent(note.event) @@ -3265,11 +1698,11 @@ class Account( return if (isWriteable()) { if (event is PrivateDmEvent) { - event.cachedContentFor(signer) + privateDMDecryptionCache.cachedDM(event) } else if (event is LnZapRequestEvent && event.isPrivateZap()) { - event.cachedPrivateZap()?.content + privateZapsDecryptionCache.cachedPrivateZap(event)?.content } else if (event is DraftEvent) { - event.preCachedDraft(signer)?.content + draftsDecryptionCache.preCachedDraft(event)?.content } else { event.content } @@ -3278,93 +1711,36 @@ class Account( } } - fun decryptContent( - note: Note, - onReady: (String) -> Unit, - ) { + suspend fun decryptContent(note: Note): String? { val event = note.event - if (event is PrivateDmEvent && isWriteable()) { - event.plainContent(signer, onReady) - } else if (event is LnZapRequestEvent) { - decryptZapContentAuthor(note) { onReady(it.content) } - } else if (event is DraftEvent) { - event.cachedDraft(signer) { - onReady(it.content) - } - } else { - event?.content?.let { onReady(it) } - } - } - - fun decryptZapContentAuthor( - note: Note, - onReady: (Event) -> Unit, - ) { - val event = note.event - if (event is LnZapRequestEvent) { + return if (event is PrivateDmEvent && isWriteable()) { + privateDMDecryptionCache.decryptDM(event) + } else if (event is LnZapRequestEvent && isWriteable()) { if (event.isPrivateZap()) { if (isWriteable()) { - event.decryptPrivateZap(signer) { onReady(it) } + privateZapsDecryptionCache.decryptPrivateZap(event)?.content + } else { + null } } else { - onReady(event) + event.content } + } else if (event is DraftEvent && isWriteable()) { + draftsDecryptionCache.cachedDraft(event)?.content + } else { + event?.content } } - // Takes a User's relay list and adds the types of feeds they are active for. - fun kind3Relays(): Array? { - val usersRelayList = - (userProfile().latestContactList ?: settings.backupContactList) - ?.relays() - ?.map { - val url = RelayUrlFormatter.normalize(it.key) - - val localFeedTypes = - settings.localRelays - .firstOrNull { localRelay -> RelayUrlFormatter.normalize(localRelay.url) == url } - ?.feedTypes - ?.minus(setOf(FeedType.SEARCH, FeedType.WALLET_CONNECT)) - ?: Constants.defaultRelays - .filter { defaultRelay -> RelayUrlFormatter.normalize(defaultRelay.url) == url } - .firstOrNull() - ?.feedTypes - ?: Constants.activeTypesGlobalChats - - RelaySetupInfo(url, it.value.read, it.value.write, localFeedTypes) - }?.ifEmpty { null } ?: return null - - return usersRelayList.toTypedArray() - } - - fun convertLocalRelays(): Array = - settings.localRelays - .map { - RelaySetupInfo( - RelayUrlFormatter.normalize(it.url), - it.read, - it.write, - it.feedTypes.minus(setOf(FeedType.SEARCH, FeedType.WALLET_CONNECT)), - ) - }.toTypedArray() - - fun activeGlobalRelays(): Array = - connectToRelays.value - .filter { it.feedTypes.contains(FeedType.GLOBAL) } - .map { it.url } - .toTypedArray() - - fun activeWriteRelays(): List = connectToRelays.value.filter { it.write } + suspend fun decryptZapOrNull(event: LnZapRequestEvent): LnZapPrivateEvent? = if (event.isPrivateZap() && isWriteable()) privateZapsDecryptionCache.decryptPrivateZap(event) else null fun isAllHidden(users: Set): Boolean = users.all { isHidden(it) } fun isHidden(user: User) = isHidden(user.pubkeyHex) - fun isHidden(userHex: String): Boolean = - flowHiddenUsers.value.hiddenUsers.contains(userHex) || - flowHiddenUsers.value.spammers.contains(userHex) + fun isHidden(userHex: String): Boolean = hiddenUsers.flow.value.isUserHidden(userHex) - fun followingKeySet(): Set = liveKind3Follows.value.authors + fun followingKeySet(): Set = kind3FollowList.flow.value.authors fun isAcceptable(user: User): Boolean { if (userProfile().pubkeyHex == user.pubkeyHex) { @@ -3422,234 +1798,48 @@ class Account( } return ( - note.reportsBy(liveKind3Follows.value.authorsPlusMe) + - (note.author?.reportsBy(liveKind3Follows.value.authorsPlusMe) ?: emptyList()) + + note.reportsBy(kind3FollowList.flow.value.authorsPlusMe) + + (note.author?.reportsBy(kind3FollowList.flow.value.authorsPlusMe) ?: emptyList()) + innerReports ).toSet() } - fun saveKind3RelayList(value: List) { - settings.updateLocalRelays(value.toSet()) - sendKind3RelayList( - value.associate { it.url to ReadWrite(it.read, it.write) }, - ) - } + suspend fun saveDMRelayList(dmRelays: List) = sendLiterallyEverywhere(dmRelayList.saveRelayList(dmRelays)) - fun getDMRelayListNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(ChatMessageRelayListEvent.createAddress(signer.pubKey)) + suspend fun savePrivateOutboxRelayList(relays: List) = sendMyPublicAndPrivateOutbox(privateStorageRelayList.saveRelayList(relays)) - fun getDMRelayListFlow(): StateFlow = getDMRelayListNote().flow().metadata.stateFlow + suspend fun saveSearchRelayList(searchRelays: List) = sendMyPublicAndPrivateOutbox(searchRelayList.saveRelayList(searchRelays)) - fun getDMRelayList(): ChatMessageRelayListEvent? = getDMRelayListNote().event as? ChatMessageRelayListEvent + suspend fun saveIndexerRelayList(trustedRelays: List) = sendMyPublicAndPrivateOutbox(indexerRelayList.saveRelayList(trustedRelays)) - fun saveDMRelayList(dmRelays: List) { - if (!isWriteable()) return + suspend fun saveBroadcastRelayList(trustedRelays: List) = sendMyPublicAndPrivateOutbox(broadcastRelayList.saveRelayList(trustedRelays)) - val relayListForDMs = getDMRelayList() - if (relayListForDMs != null && relayListForDMs.tags.isNotEmpty()) { - ChatMessageRelayListEvent.updateRelayList( - earlierVersion = relayListForDMs, - relays = dmRelays, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } else { - ChatMessageRelayListEvent.createFromScratch( - relays = dmRelays, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } - } + suspend fun saveProxyRelayList(trustedRelays: List) = sendMyPublicAndPrivateOutbox(proxyRelayList.saveRelayList(trustedRelays)) - fun getPrivateOutboxRelayListNote(): AddressableNote = - LocalCache.getOrCreateAddressableNote( - PrivateOutboxRelayListEvent.createAddress(signer.pubKey), - ) + suspend fun saveTrustedRelayList(trustedRelays: List) = sendMyPublicAndPrivateOutbox(trustedRelayList.saveRelayList(trustedRelays)) - fun getPrivateOutboxRelayListFlow(): StateFlow = getPrivateOutboxRelayListNote().flow().metadata.stateFlow + suspend fun saveBlockedRelayList(blockedRelays: List) = sendMyPublicAndPrivateOutbox(blockedRelayList.saveRelayList(blockedRelays)) - fun getPrivateOutboxRelayList(): PrivateOutboxRelayListEvent? = getPrivateOutboxRelayListNote().event as? PrivateOutboxRelayListEvent + suspend fun sendNip65RelayList(relays: List) = sendLiterallyEverywhere(nip65RelayList.saveRelayList(relays)) - fun savePrivateOutboxRelayList(relays: List) { - if (!isWriteable()) return + suspend fun sendFileServersList(servers: List) = sendMyPublicAndPrivateOutbox(fileStorageServers.saveFileServersList(servers)) - val relayListForPrivateOutbox = getPrivateOutboxRelayList() - - if (relayListForPrivateOutbox != null && !relayListForPrivateOutbox.cachedPrivateTags().isNullOrEmpty()) { - PrivateOutboxRelayListEvent.updateRelayList( - earlierVersion = relayListForPrivateOutbox, - relays = relays, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } else { - PrivateOutboxRelayListEvent.createFromScratch( - relays = relays, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } - } - - fun getSearchRelayListNote(): AddressableNote = - LocalCache.getOrCreateAddressableNote( - SearchRelayListEvent.createAddress(signer.pubKey), - ) - - fun getSearchRelayListFlow(): StateFlow = getSearchRelayListNote().flow().metadata.stateFlow - - fun getSearchRelayList(): SearchRelayListEvent? = getSearchRelayListNote().event as? SearchRelayListEvent - - fun saveSearchRelayList(searchRelays: List) { - if (!isWriteable()) return - - val relayListForSearch = getSearchRelayList() - - if (relayListForSearch != null && relayListForSearch.tags.isNotEmpty()) { - SearchRelayListEvent.updateRelayList( - earlierVersion = relayListForSearch, - relays = searchRelays, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } else { - SearchRelayListEvent.createFromScratch( - relays = searchRelays, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } - } - - fun getNIP65RelayListNote(pubkey: HexKey = signer.pubKey): AddressableNote = - LocalCache.getOrCreateAddressableNote( - AdvertisedRelayListEvent.createAddress(pubkey), - ) - - fun getNIP65RelayListFlow(pubkey: HexKey = signer.pubKey): StateFlow = getNIP65RelayListNote(pubkey).flow().metadata.stateFlow - - fun getNIP65RelayList(pubkey: HexKey = signer.pubKey): AdvertisedRelayListEvent? = getNIP65RelayListNote(pubkey).event as? AdvertisedRelayListEvent - - fun sendNip65RelayList(relays: List) { - if (!isWriteable()) return - - val nip65RelayList = getNIP65RelayList() - - if (nip65RelayList != null) { - AdvertisedRelayListEvent.updateRelayList( - earlierVersion = nip65RelayList, - relays = relays, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } else { - AdvertisedRelayListEvent.createFromScratch( - relays = relays, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } - } - - fun getFileServersList(): FileServersEvent? = getFileServersNote().event as? FileServersEvent - - fun getFileServersListFlow(): StateFlow = getFileServersNote().flow().metadata.stateFlow - - fun getFileServersNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(FileServersEvent.createAddress(userProfile().pubkeyHex)) - - fun getBlossomServersList(): BlossomServersEvent? = getBlossomServersNote().event as? BlossomServersEvent - - fun getBlossomServersListFlow(): StateFlow = getBlossomServersNote().flow().metadata.stateFlow - - fun getBlossomServersNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(BlossomServersEvent.createAddress(userProfile().pubkeyHex)) - - fun host(url: String): String = - try { - URIReference.parse(url).host.value - } catch (e: Exception) { - url - } - - fun mergeServerList( - nip96: FileServersEvent?, - blossom: BlossomServersEvent?, - ): List { - val nip96servers = nip96?.servers()?.map { ServerName(host(it), it, ServerType.NIP96) } ?: emptyList() - val blossomServers = blossom?.servers()?.map { ServerName(host(it), it, ServerType.Blossom) } ?: emptyList() - - val result = (nip96servers + blossomServers).ifEmpty { DEFAULT_MEDIA_SERVERS } - - return result + ServerName("NIP95", "", ServerType.NIP95) - } - - fun sendFileServersList(servers: List) { - if (!isWriteable()) return - - val serverList = getFileServersList() - - val template = - if (serverList != null && serverList.tags.isNotEmpty()) { - FileServersEvent.replaceServers(serverList, servers) - } else { - FileServersEvent.build(servers) - } - - signer.sign(template) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } - - fun sendBlossomServersList(servers: List) { - if (!isWriteable()) return - - val serverList = getBlossomServersList() - - if (serverList != null && serverList.tags.isNotEmpty()) { - BlossomServersEvent.updateRelayList( - earlierVersion = serverList, - relays = servers, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } else { - BlossomServersEvent.createFromScratch( - relays = servers, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsume(it, null) - } - } - } + suspend fun sendBlossomServersList(servers: List) = sendMyPublicAndPrivateOutbox(blossomServers.saveBlossomServersList(servers)) fun getAllPeopleLists(): List = getAllPeopleLists(signer.pubKey) fun getAllPeopleLists(pubkey: HexKey): List = - LocalCache.addressables + cache.addressables .filter { _, addressableNote -> - val event = (addressableNote.event as? PeopleListEvent) - event != null && - event.pubKey == pubkey && - (event.hasAnyTaggedUser() || event.cachedPrivateTags()?.isNotEmpty() == true) + val noteEvent = addressableNote.event + + if (noteEvent is PeopleListEvent) { + noteEvent.pubKey == pubkey && (noteEvent.hasAnyTaggedUser() || peopleListDecryptionCache.cachedUserIdSet(noteEvent).isNotEmpty()) + } else if (noteEvent is FollowListEvent) { + noteEvent.pubKey == pubkey && noteEvent.hasAnyTaggedUser() + } else { + false + } } fun markAsRead( @@ -3671,280 +1861,96 @@ class Account( fun markDonatedInThisVersion() = settings.markDonatedInThisVersion(BuildConfig.VERSION_NAME) - fun shouldUseTorForImageDownload() = - when (settings.torSettings.torType.value) { - TorType.OFF -> false - TorType.INTERNAL -> settings.torSettings.imagesViaTor.value - TorType.EXTERNAL -> settings.torSettings.imagesViaTor.value - } - - fun shouldUseTorForVideoDownload() = - when (settings.torSettings.torType.value) { - TorType.OFF -> false - TorType.INTERNAL -> settings.torSettings.videosViaTor.value - TorType.EXTERNAL -> settings.torSettings.videosViaTor.value - } - - fun shouldUseTorForVideoDownload(url: String) = - when (settings.torSettings.torType.value) { - TorType.OFF -> false - TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.videosViaTor.value) - TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.videosViaTor.value) - } - - fun shouldUseTorForPreviewUrl(url: String) = - when (settings.torSettings.torType.value) { - TorType.OFF -> false - TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.urlPreviewsViaTor.value) - TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.urlPreviewsViaTor.value) - } - - fun shouldUseTorForTrustedRelays() = - when (settings.torSettings.torType.value) { - TorType.OFF -> false - TorType.INTERNAL -> settings.torSettings.trustedRelaysViaTor.value - TorType.EXTERNAL -> settings.torSettings.trustedRelaysViaTor.value - } - - fun shouldUseTorForDirty(dirtyUrl: String) = shouldUseTorForClean(RelayUrlFormatter.normalize(dirtyUrl)) - - fun shouldUseTorForClean(normalizedUrl: String) = - when (settings.torSettings.torType.value) { - TorType.OFF -> false - TorType.INTERNAL -> shouldUseTor(normalizedUrl) - TorType.EXTERNAL -> shouldUseTor(normalizedUrl) - } - - private fun checkLocalHostOnionAndThen( - normalizedUrl: String, - final: Boolean, - ): Boolean = checkLocalHostOnionAndThen(normalizedUrl, settings.torSettings.onionRelaysViaTor.value, final) - - private fun checkLocalHostOnionAndThen( - normalizedUrl: String, - isOnionRelaysActive: Boolean, - final: Boolean, - ): Boolean = - if (isLocalHost(normalizedUrl)) { - false - } else if (isOnionUrl(normalizedUrl)) { - isOnionRelaysActive - } else { - final - } - - private fun shouldUseTor(normalizedUrl: String): Boolean = - if (isLocalHost(normalizedUrl)) { - false - } else if (isOnionUrl(normalizedUrl)) { - settings.torSettings.onionRelaysViaTor.value - } else if (isDMRelay(normalizedUrl)) { - settings.torSettings.dmRelaysViaTor.value - } else if (isTrustedRelay(normalizedUrl)) { - settings.torSettings.trustedRelaysViaTor.value - } else { - settings.torSettings.newRelaysViaTor.value - } - - fun shouldUseTorForMoneyOperations(url: String) = - when (settings.torSettings.torType.value) { - TorType.OFF -> false - TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.moneyOperationsViaTor.value) - TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.moneyOperationsViaTor.value) - } - - fun shouldUseTorForNIP05(url: String) = - when (settings.torSettings.torType.value) { - TorType.OFF -> false - TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.nip05VerificationsViaTor.value) - TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.nip05VerificationsViaTor.value) - } - - fun shouldUseTorForNIP96(url: String) = - when (settings.torSettings.torType.value) { - TorType.OFF -> false - TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.nip96UploadsViaTor.value) - TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.nip96UploadsViaTor.value) - } - - fun isLocalHost(url: String) = url.contains("//127.0.0.1") || url.contains("//localhost") - - fun isOnionUrl(url: String) = url.contains(".onion") - - fun isDMRelay(url: String) = url in normalizedDmRelaySet.value - - fun isTrustedRelay(url: String): Boolean = connectToRelays.value.any { it.url == url } || url == settings.zapPaymentRequest?.relayUri - - fun otsResolver(): OtsResolver = - OtsResolverBuilder().build( - Amethyst.instance.okHttpClients, - ::shouldUseTorForMoneyOperations, - Amethyst.instance.otsBlockHeightCache, - ) - init { Log.d("AccountRegisterObservers", "Init") - settings.backupContactList?.let { - Log.d("AccountRegisterObservers", "Loading saved contacts ${it.toJson()}") - - GlobalScope.launch(Dispatchers.IO) { LocalCache.consume(it) } - } - - settings.backupUserMetadata?.let { - Log.d("AccountRegisterObservers", "Loading saved user metadata ${it.toJson()}") - - GlobalScope.launch(Dispatchers.IO) { LocalCache.consume(it, null) } - } - - settings.backupDMRelayList?.let { - Log.d("AccountRegisterObservers", "Loading saved DM Relay List ${it.toJson()}") - GlobalScope.launch(Dispatchers.IO) { LocalCache.verifyAndConsume(it, null) } - } - - settings.backupNIP65RelayList?.let { - Log.d("AccountRegisterObservers", "Loading saved nip65 relay list ${it.toJson()}") - GlobalScope.launch(Dispatchers.IO) { LocalCache.verifyAndConsume(it, null) } - } - - settings.backupSearchRelayList?.let { - Log.d("AccountRegisterObservers", "Loading saved search relay list ${it.toJson()}") - GlobalScope.launch(Dispatchers.IO) { LocalCache.verifyAndConsume(it, null) } - } - - settings.backupPrivateHomeRelayList?.let { event -> - Log.d("AccountRegisterObservers", "Loading saved private home relay list ${event.toJson()}") - GlobalScope.launch(Dispatchers.IO) { - event.privateTags(signer) { - LocalCache.verifyAndConsume(event, null) - } - } - } - - settings.backupAppSpecificData?.let { event -> - Log.d("AccountRegisterObservers", "Loading saved app specific data ${event.toJson()}") - GlobalScope.launch(Dispatchers.IO) { - LocalCache.verifyAndConsume(event, null) - signer.decrypt(event.content, event.pubKey) { decrypted -> - try { - val syncedSettings = EventMapper.mapper.readValue(decrypted) - settings.syncedSettings.updateFrom(syncedSettings) - } catch (e: Throwable) { - if (e is CancellationException) throw e - Log.w("LocalPreferences", "Error Decoding latestAppSpecificData from Preferences with value $decrypted", e) - e.printStackTrace() - AccountSyncedSettingsInternal() - } - } - } - } - - settings.backupMuteList?.let { - Log.d("AccountRegisterObservers", "Loading saved mute list ${it.toJson()}") - GlobalScope.launch(Dispatchers.IO) { LocalCache.verifyAndConsume(it, null) } - } - - // saves contact list for the next time. - scope.launch(Dispatchers.Default) { - Log.d("AccountRegisterObservers", "Kind 0 Collector Start") - userProfile().flow().metadata.stateFlow.collect { - Log.d("AccountRegisterObservers", "Updating Kind 0 ${userProfile().toBestDisplayName()}") - settings.updateUserMetadata(userProfile().latestMetadata) - } - } - - // saves contact list for the next time. - scope.launch(Dispatchers.Default) { - Log.d("AccountRegisterObservers", "Kind 3 Collector Start") - userProfile().flow().follows.stateFlow.collect { - Log.d("AccountRegisterObservers", "Updating Kind 3 ${userProfile().toBestDisplayName()}") - settings.updateContactListTo(userProfile().latestContactList) - } - } scope.launch(Dispatchers.Default) { - Log.d("AccountRegisterObservers", "NIP-17 Relay List Collector Start") - getDMRelayListFlow().collect { - Log.d("AccountRegisterObservers", "Updating DM Relay List for ${userProfile().toBestDisplayName()}") - (it.note.event as? ChatMessageRelayListEvent)?.let { - settings.updateDMRelayList(it) - } - } - } - - scope.launch(Dispatchers.Default) { - Log.d("AccountRegisterObservers", "NIP-65 Relay List Collector Start") - getNIP65RelayListFlow().collect { - Log.d("AccountRegisterObservers", "Updating NIP-65 List for ${userProfile().toBestDisplayName()}") - (it.note.event as? AdvertisedRelayListEvent)?.let { - settings.updateNIP65RelayList(it) - } - } - } - - scope.launch(Dispatchers.Default) { - Log.d("AccountRegisterObservers", "Search Relay List Collector Start") - getSearchRelayListFlow().collect { - Log.d("AccountRegisterObservers", "Updating Search Relay List for ${userProfile().toBestDisplayName()}") - (it.note.event as? SearchRelayListEvent)?.let { - settings.updateSearchRelayList(it) - } - } - } - - scope.launch(Dispatchers.Default) { - Log.d("AccountRegisterObservers", "Private Home Relay List Collector Start") - getPrivateOutboxRelayListFlow().collect { - Log.d("AccountRegisterObservers", "Updating Private Home Relay List for ${userProfile().toBestDisplayName()}") - (it.note.event as? PrivateOutboxRelayListEvent)?.let { - settings.updatePrivateHomeRelayList(it) - } - } - } - - scope.launch(Dispatchers.Default) { - Log.d("AccountRegisterObservers", "Mute List Collector Start") - getMuteListFlow().collect { - Log.d("AccountRegisterObservers", "Updating Mute List for ${userProfile().toBestDisplayName()}") - (it.note.event as? MuteListEvent)?.let { - settings.updateMuteList(it) - } - } - } - - scope.launch(Dispatchers.Default) { - Log.d("AccountRegisterObservers", "AppSpecificData Collector Start") - getAppSpecificDataFlow().collect { - Log.d("AccountRegisterObservers", "Updating AppSpecificData for ${userProfile().toBestDisplayName()}") - (it.note.event as? AppSpecificDataEvent)?.let { - signer.decrypt(it.content, it.pubKey) { decrypted -> - val syncedSettings = - try { - EventMapper.mapper.readValue(decrypted) - } catch (e: Throwable) { - if (e is CancellationException) throw e - Log.w("LocalPreferences", "Error Decoding latestAppSpecificData from Preferences with value $decrypted", e) - e.printStackTrace() - AccountSyncedSettingsInternal() - } - - settings.updateAppSpecificData(it, syncedSettings) - } - } - } - } - - scope.launch(Dispatchers.Default) { - LocalCache.antiSpam.flowSpam.collect { + cache.antiSpam.flowSpam.collect { it.cache.spamMessages.snapshot().values.forEach { spammer -> - if (spammer.pubkeyHex !in transientHiddenUsers.value && spammer.duplicatedMessages.size >= 5) { + if (!hiddenUsers.isHidden(spammer.pubkeyHex) && spammer.shouldHide()) { if (spammer.pubkeyHex != userProfile().pubkeyHex && spammer.pubkeyHex !in followingKeySet()) { - transientHiddenUsers.update { - it + spammer.pubkeyHex - } + hiddenUsers.hideUser(spammer.pubkeyHex) } } } } } + + scope.launch(Dispatchers.Default) { + Log.d("DB UPGRADE", "Migrating List") + delay(1000 * 60 * 1) + // waits 5 minutes before migrating the list. + val contactList = userProfile().latestContactList + val oldChannels = contactList?.taggedEventIds()?.toSet()?.mapNotNull { cache.getPublicChatChannelIfExists(it) } + + if (oldChannels != null && oldChannels.isNotEmpty()) { + Log.d("DB UPGRADE", "Migrating List with ${oldChannels.size} old channels ") + val existingChannels = publicChatList.flowSet.value + + val needsToUpgrade = oldChannels.filter { it.idHex !in existingChannels } + + Log.d("DB UPGRADE", "Migrating List with ${needsToUpgrade.size} needsToUpgrade ") + + if (needsToUpgrade.isNotEmpty()) { + Log.d("DB UPGRADE", "Migrating List") + runCatching { + sendMyPublicAndPrivateOutbox(publicChatList.follow(oldChannels)) + } + } + } + + val oldCommunities = contactList?.taggedAddresses()?.toSet()?.map { cache.getOrCreateAddressableNote(it) } + + if (oldCommunities != null && oldCommunities.isNotEmpty()) { + Log.d("DB UPGRADE", "Migrating List with ${oldCommunities.size} old communities ") + val existingCommunities = communityList.flowSet.value + + val needsToUpgrade = oldCommunities.filter { it.idHex !in existingCommunities } + + Log.d("DB UPGRADE", "Migrating List with ${needsToUpgrade.size} needsToUpgrade ") + + if (needsToUpgrade.isNotEmpty()) { + Log.d("DB UPGRADE", "Migrating List") + runCatching { + sendMyPublicAndPrivateOutbox(communityList.follow(oldCommunities)) + } + } + } + + val oldHashtags = contactList?.hashtags()?.toSet() + + if (oldHashtags != null && oldHashtags.isNotEmpty()) { + Log.d("DB UPGRADE", "Migrating List with ${oldHashtags.size} old hashtags ") + val existingHashtags = hashtagList.flow.value + + val needsToUpgrade = oldHashtags.filter { it !in existingHashtags } + + Log.d("DB UPGRADE", "Migrating List with ${needsToUpgrade.size} needsToUpgrade ") + + if (needsToUpgrade.isNotEmpty()) { + Log.d("DB UPGRADE", "Migrating List") + runCatching { + sendMyPublicAndPrivateOutbox(hashtagList.follow(oldHashtags.toList())) + } + } + } + + val oldGeohashes = contactList?.geohashes()?.toSet() + if (oldGeohashes != null && oldGeohashes.isNotEmpty()) { + Log.d("DB UPGRADE", "Migrating List with ${oldGeohashes.size} old geohashes ") + val existingGeohashes = geohashList.flow.value + + val needsToUpgrade = oldGeohashes.filter { it !in existingGeohashes } + + Log.d("DB UPGRADE", "Migrating List with ${needsToUpgrade.size} needsToUpgrade ") + + if (needsToUpgrade.isNotEmpty()) { + Log.d("DB UPGRADE", "Migrating List") + runCatching { + sendMyPublicAndPrivateOutbox(geohashList.follow(oldGeohashes.toList())) + } + } + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 4bd7f8bf01..120281127a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -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 = Constants.defaultRelays.toSet(), - var localRelayServers: Set = setOf(), + var localRelayServers: MutableStateFlow> = MutableStateFlow(setOf()), var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0], - val defaultHomeFollowList: MutableStateFlow = MutableStateFlow(KIND3_FOLLOWS), + val defaultHomeFollowList: MutableStateFlow = MutableStateFlow(ALL_FOLLOWS), val defaultStoriesFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), val defaultNotificationFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), val defaultDiscoveryFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), - var zapPaymentRequest: Nip47WalletConnect.Nip47URI? = null, + var zapPaymentRequest: MutableStateFlow = 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>> = MutableStateFlow(mapOf()), var hasDonatedInVersion: MutableStateFlow> = MutableStateFlow(setOf()), val pendingAttestations: MutableStateFlow> = MutableStateFlow>(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) { - 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) { - 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 { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt index 7aa0fef4ba..16d1a76260 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt @@ -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 = 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt index 83bc5e8b55..6efe26d4af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt index 7305d40300..4b2defdcb1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt @@ -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, -) + var duplicatedEventIds: Set, + var duplicatedEventAddresses: Set
, +) { + fun shouldHide() = duplicatedEventIds.size >= 5 || duplicatedEventAddresses.size >= 5 +} class AntiSpamFilter { - val recentMessages = LruCache(1000) + val recentEventIds = LruCache(2000) + val recentAddressables = LruCache(2000) val spamMessages = LruCache(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 } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt index 442e44fb04..153f1cd730 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt @@ -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() - var lastNoteCreatedAt: Long = 0 - private var relays = mapOf() + var lastNote: Note? = null - open fun id() = Hex.decode(idHex) + private var relays = mapOf() - 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 = 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 { + fun pruneOldMessages(): Set { 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(channel)) { - // Refreshes observers in batches. - private val bundler = BundledUpdate(300, Dispatchers.IO) + fun pruneHiddenMessages(account: Account): Set { + 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, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Constants.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Constants.kt new file mode 100644 index 0000000000..d34c7d42c6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Constants.kt @@ -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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt index f1cfd43fc8..567b61ac3e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt @@ -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) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 48cd970726..ff8a4e7df7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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,20 +24,23 @@ import android.util.Log import android.util.LruCache import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.commons.data.DeletionIndex -import com.vitorpamplona.amethyst.commons.data.LargeCache +import com.vitorpamplona.amethyst.isDebug +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.model.observables.LatestByKindAndAuthor import com.vitorpamplona.amethyst.model.observables.LatestByKindWithETag -import com.vitorpamplona.amethyst.service.NostrAccountDataSource.account +import com.vitorpamplona.amethyst.model.privateChats.ChatroomList import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.amethyst.ui.note.dateFormatter import com.vitorpamplona.ammolite.relays.BundledInsert -import com.vitorpamplona.ammolite.relays.Relay -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache -import com.vitorpamplona.quartz.blossom.BlossomServersEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent -import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent +import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent +import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId +import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent @@ -46,6 +49,7 @@ import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent +import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.relationshipStatus.RelationshipStatusEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.checkSignature @@ -54,44 +58,59 @@ import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.tagValueContains +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address -import com.vitorpamplona.quartz.nip01Core.tags.addressables.mapTaggedAddress import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.events.GenericETag import com.vitorpamplona.quartz.nip01Core.tags.events.forEachTaggedEventId import com.vitorpamplona.quartz.nip01Core.tags.events.isTaggedEvent -import com.vitorpamplona.quartz.nip01Core.tags.events.mapTaggedEventId import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUsers import com.vitorpamplona.quartz.nip01Core.verify import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent -import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver +import com.vitorpamplona.quartz.nip03Timestamp.OtsResolverBuilder import com.vitorpamplona.quartz.nip03Timestamp.VerificationState import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent -import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionIndex +import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.decodeEventIdAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.nip19Bech32.entities.Entity +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay +import com.vitorpamplona.quartz.nip19Bech32.entities.NSec import com.vitorpamplona.quartz.nip19Bech32.isATag import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelListEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelHideMessageEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMuteUserEvent +import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent @@ -102,6 +121,7 @@ import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip37Drafts.DraftEvent +import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore @@ -109,11 +129,19 @@ import com.vitorpamplona.quartz.nip40Expiration.isExpired import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent -import com.vitorpamplona.quartz.nip51Lists.RelaySetEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +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.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarRSVPEvent @@ -131,13 +159,13 @@ import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityListEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent +import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent @@ -150,15 +178,19 @@ import com.vitorpamplona.quartz.nip90Dvms.NIP90UserDiscoveryResponseEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.utils.LargeCache +import com.vitorpamplona.quartz.utils.LargeSoftCache import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableList -import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow @@ -166,19 +198,30 @@ import kotlinx.coroutines.launch import java.io.File import java.io.FileOutputStream import java.io.IOException -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter import java.util.concurrent.ConcurrentHashMap -object LocalCache { +interface ILocalCache { + fun markAsSeen( + eventId: String, + relay: NormalizedRelayUrl, + ) {} +} + +object LocalCache : ILocalCache { val antiSpam = AntiSpamFilter() - val users = LargeCache() - val notes = LargeCache() - val addressables = LargeCache() - val channels = LargeCache() - val awaitingPaymentRequests = ConcurrentHashMap Unit>>(10) + val users = LargeSoftCache() + val notes = LargeSoftCache() + val addressables = LargeSoftCache() + + val chatroomList = LargeCache() + val publicChatChannels = LargeCache() + val liveChatChannels = LargeCache() + val ephemeralChannels = LargeCache() + + val awaitingPaymentRequests = ConcurrentHashMap Unit>>(10) + + val relayHints = HintIndexer() val deletionIndex = DeletionIndex() @@ -254,8 +297,6 @@ object LocalCache { } fun checkGetOrCreateUser(key: String): User? { - // checkNotInMainThread() - if (isValidHex(key)) { return getOrCreateUser(key) } @@ -263,7 +304,6 @@ object LocalCache { } fun getOrCreateUser(key: HexKey): User { - // checkNotInMainThread() require(isValidHex(key = key)) { "$key is not a valid hex" } return users.getOrCreate(key) { @@ -276,15 +316,19 @@ object LocalCache { return users.get(key) } - fun getAddressableNoteIfExists(key: String): AddressableNote? = addressables.get(key) + fun getAddressableNoteIfExists(key: String): AddressableNote? = Address.parse(key)?.let { addressables.get(it) } - fun getAddressableNoteIfExists(address: Address): AddressableNote? = getAddressableNoteIfExists(address.toValue()) + fun getAddressableNoteIfExists(address: Address): AddressableNote? = addressables.get(address) - fun getNoteIfExists(key: String): Note? = addressables.get(key) ?: notes.get(key) + fun getNoteIfExists(key: String): Note? = if (key.length == 64) notes.get(key) else Address.parse(key)?.let { addressables.get(it) } fun getNoteIfExists(key: ETag): Note? = notes.get(key.eventId) - fun getChannelIfExists(key: String): Channel? = channels.get(key) + fun getPublicChatChannelIfExists(key: String): PublicChatChannel? = publicChatChannels.get(key) + + fun getEphemeralChatChannelIfExists(key: RoomId): EphemeralChatChannel? = ephemeralChannels.get(key) + + fun getLiveActivityChannelIfExists(key: Address): LiveActivitiesChannel? = liveChatChannels.get(key) fun getNoteIfExists(event: Event): Note? = if (event is AddressableEvent) { @@ -301,8 +345,6 @@ object LocalCache { } fun checkGetOrCreateNote(etag: ETag): Note? { - checkNotInMainThread() - if (isValidHex(etag.eventId)) { return getOrCreateNote(etag) } @@ -310,8 +352,6 @@ object LocalCache { } fun checkGetOrCreateNote(key: String): Note? { - checkNotInMainThread() - if (ATag.isATag(key)) { return checkGetOrCreateAddressableNote(key) } @@ -340,8 +380,6 @@ object LocalCache { idHex: String, note: Note, ): Note { - checkNotInMainThread() - require(isValidHex(idHex)) { "$idHex is not a valid hex" } return notes.getOrCreate(idHex) { @@ -350,8 +388,6 @@ object LocalCache { } fun getOrCreateNote(idHex: String): Note { - checkNotInMainThread() - require(isValidHex(idHex)) { "$idHex is not a valid hex" } return notes.getOrCreate(idHex) { @@ -359,31 +395,24 @@ object LocalCache { } } - fun getOrCreateChannel( - key: String, - channelFactory: (String) -> Channel, - ): Channel { - checkNotInMainThread() + fun getOrCreateChatroomList(key: HexKey): ChatroomList = chatroomList.getOrCreate(key) { ChatroomList(key) } - return channels.getOrCreate(key, channelFactory) - } + fun getOrCreatePublicChatChannel(key: HexKey): PublicChatChannel = publicChatChannels.getOrCreate(key) { PublicChatChannel(key) } - fun checkGetOrCreateChannel(key: String): Channel? { - checkNotInMainThread() + fun getOrCreateLiveChannel(key: Address): LiveActivitiesChannel = liveChatChannels.getOrCreate(key) { LiveActivitiesChannel(key) } + fun getOrCreateEphemeralChannel(key: RoomId): EphemeralChatChannel = ephemeralChannels.getOrCreate(key) { EphemeralChatChannel(key) } + + fun checkGetOrCreatePublicChatChannel(key: String): PublicChatChannel? { if (isValidHex(key)) { - return channels.getOrCreate(key) { PublicChatChannel(key) } - } - - val address = Address.parse(key) - if (address != null) { - return channels.getOrCreate(address.toValue()) { LiveActivitiesChannel(address) } + return getOrCreatePublicChatChannel(key) } return null } private fun isValidHex(key: String): Boolean { if (key.isBlank()) return false + if (key.length != 64) return false if (key.contains(":")) return false return Hex.isHex(key) @@ -402,10 +431,7 @@ object LocalCache { null } - fun getOrCreateAddressableNoteInternal(key: Address): AddressableNote = - addressables.getOrCreate(key.toValue()) { - AddressableNote(key) - } + fun getOrCreateAddressableNoteInternal(key: Address): AddressableNote = addressables.getOrCreate(key) { AddressableNote(key) } fun getOrCreateAddressableNote(key: Address): AddressableNote { val note = getOrCreateAddressableNoteInternal(key) @@ -424,17 +450,17 @@ object LocalCache { note.author = checkGetOrCreateUser(possibleAuthor) } val relayHint = key.relay - if (!relayHint.isNullOrBlank()) { - val relay = RelayBriefInfoCache.get(RelayUrlFormatter.normalize(relayHint)) - note.addRelayBrief(relay) + if (relayHint != null) { + note.addRelay(relayHint) } return note } fun consume( event: MetadataEvent, - relay: Relay?, - ) { + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { // new event val oldUser = getOrCreateUser(event.pubKey) val currentMetadata = oldUser.latestMetadata @@ -443,82 +469,89 @@ object LocalCache { oldUser.latestMetadata = event val newUserMetadata = event.contactMetaData() - if (newUserMetadata != null) { + if (newUserMetadata != null && (wasVerified || justVerify(event))) { oldUser.updateUserInfo(newUserMetadata, event) if (relay != null) { oldUser.addRelayBeingUsed(relay, event.createdAt) - if (!RelayUrlFormatter.isLocalHost(relay.url)) { - oldUser.latestMetadataRelay = relay.url + if (!relay.isLocalHost()) { + oldUser.latestMetadataRelay = relay } } + + return true } - // Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex()} ${oldUser.toBestDisplayName()} from ${relay?.url}") - } else { - // Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} - // ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}") } + + return false } - fun consume(event: ContactListEvent) { + fun consume( + event: ContactListEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { val user = getOrCreateUser(event.pubKey) // avoids processing empty contact lists. - if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !event.tags.isEmpty()) { + if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !event.tags.isEmpty() && (wasVerified || justVerify(event))) { user.updateContactList(event) // Log.d("CL", "Consumed contact list ${user.toNostrUri()} ${event.relays()?.size}") updateObservables(event) + + return true } + + return false } - fun consume(event: BookmarkListEvent) { - val user = getOrCreateUser(event.pubKey) - if (user.latestBookmarkList == null || event.createdAt > user.latestBookmarkList!!.createdAt) { - if (event.dTag() == "bookmark") { - user.updateBookmark(event) - } - // Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex} ${oldUser.toBestDisplayName()}") - } else { - // Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} - // ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}") - } - } - - fun formattedDateTime(timestamp: Long): String = - Instant - .ofEpochSecond(timestamp) - .atZone(ZoneId.systemDefault()) - .format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a")) + fun consume( + event: BookmarkListEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: TextNoteEvent, - relay: Relay? = null, - ) = consumeRegularEvent(event, relay) + relay: NormalizedRelayUrl? = null, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) + + fun consume( + event: PublicMessageEvent, + relay: NormalizedRelayUrl? = null, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: TorrentEvent, - relay: Relay?, - ) = consumeRegularEvent(event, relay) + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: InteractiveStoryPrologueEvent, - relay: Relay?, - ) = consumeBaseReplaceable(event, relay) + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: InteractiveStorySceneEvent, - relay: Relay?, - ) = consumeBaseReplaceable(event, relay) + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: InteractiveStoryReadingStateEvent, - relay: Relay?, - ) = consumeBaseReplaceable(event, relay) + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) fun consumeRegularEvent( event: Event, - relay: Relay?, - ) { + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { val note = getOrCreateNote(event.id) val author = getOrCreateUser(event.pubKey) @@ -528,84 +561,125 @@ object LocalCache { } // Already processed this event. - if (note.event != null) return + if (note.event != null) return false - val replyTo = computeReplyTo(event) - - if (event is BaseThreadedEvent && antiSpam.isSpam(event, relay)) { - return + if (event is BaseNoteEvent && antiSpam.isSpam(event, relay)) { + return false } - note.loadEvent(event, author, replyTo) + if (wasVerified || justVerify(event)) { + val replyTo = computeReplyTo(event) - // Counts the replies - replyTo.forEach { it.addReply(note) } + note.loadEvent(event, author, replyTo) - refreshObservers(note) + // Counts the replies + replyTo.forEach { it.addReply(note) } + + refreshNewNoteObservers(note) + + return true + } else { + return false + } } fun consume( event: PictureEvent, - relay: Relay?, - ) = consumeRegularEvent(event, relay) + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) + fun consume( + event: VoiceEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) + + fun consume( + event: VoiceReplyEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) + + @Suppress("DEPRECATION") fun consume( event: TorrentCommentEvent, - relay: Relay?, - ) = consumeRegularEvent(event, relay) + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: NIP90ContentDiscoveryResponseEvent, - relay: Relay?, - ) = consumeRegularEvent(event, relay) + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: NIP90ContentDiscoveryRequestEvent, - relay: Relay?, - ) = consumeRegularEvent(event, relay) + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: NIP90StatusEvent, - relay: Relay?, - ) = consumeRegularEvent(event, relay) + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: NIP90UserDiscoveryResponseEvent, - relay: Relay?, - ) = consumeRegularEvent(event, relay) + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: NIP90UserDiscoveryRequestEvent, - relay: Relay?, - ) = consumeRegularEvent(event, relay) + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) + + fun consume( + event: GoalEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: GitPatchEvent, - relay: Relay?, - ) = consumeRegularEvent(event, relay) + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: GitIssueEvent, - relay: Relay?, - ) = consumeRegularEvent(event, relay) + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) + @Suppress("DEPRECATION") fun consume( event: GitReplyEvent, - relay: Relay?, - ) = consumeRegularEvent(event, relay) + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: LongTextNoteEvent, - relay: Relay?, - ) { + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { val version = getOrCreateNote(event.id) val note = getOrCreateAddressableNote(event.address()) val author = getOrCreateUser(event.pubKey) - if (version.event == null) { - version.loadEvent(event, author, emptyList()) - version.moveAllReferencesTo(note) - } + val isVerified = + if (version.event == null && (wasVerified || justVerify(event))) { + version.loadEvent(event, author, emptyList()) + version.moveAllReferencesTo(note) + true + } else { + wasVerified + } if (relay != null) { author.addRelayBeingUsed(relay, event.createdAt) @@ -613,33 +687,44 @@ object LocalCache { } // Already processed this event. - if (note.event?.id == event.id) return + if (note.event?.id == event.id) return wasVerified if (antiSpam.isSpam(event, relay)) { - return + return false } - val replyTo = computeReplyTo(event) + if (isVerified || justVerify(event)) { + val replyTo = computeReplyTo(event) - if (event.createdAt > (note.createdAt() ?: 0)) { - note.loadEvent(event, author, replyTo) + if (event.createdAt > (note.createdAt() ?: 0)) { + note.loadEvent(event, author, replyTo) - refreshObservers(note) + refreshNewNoteObservers(note) + + return true + } } + + return false } fun consume( event: WikiNoteEvent, - relay: Relay?, - ) { + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { val version = getOrCreateNote(event.id) val note = getOrCreateAddressableNote(event.address()) val author = getOrCreateUser(event.pubKey) - if (version.event == null) { - version.loadEvent(event, author, emptyList()) - version.moveAllReferencesTo(note) - } + val isVerified = + if (version.event == null && (wasVerified || justVerify(event))) { + version.loadEvent(event, author, emptyList()) + version.moveAllReferencesTo(note) + true + } else { + wasVerified + } if (relay != null) { author.addRelayBeingUsed(relay, event.createdAt) @@ -647,21 +732,28 @@ object LocalCache { } // Already processed this event. - if (note.event?.id == event.id) return + if (note.event?.id == event.id) return wasVerified if (antiSpam.isSpam(event, relay)) { - return + return false } - val replyTo = computeReplyTo(event) + if (isVerified || justVerify(event)) { + if (event.createdAt > (note.createdAt() ?: 0)) { + val replyTo = computeReplyTo(event) - if (event.createdAt > (note.createdAt() ?: 0)) { - note.loadEvent(event, author, replyTo) + note.loadEvent(event, author, replyTo) - refreshObservers(note) + refreshNewNoteObservers(note) + + return true + } } + + return false } + @Suppress("DEPRECATION") fun computeReplyTo(event: Event): List = when (event) { is PollNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } @@ -671,6 +763,8 @@ object LocalCache { is TextNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } is CommentEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } + is VoiceReplyEvent -> event.markedReplyTos().mapNotNull { checkGetOrCreateNote(it) } + is ChatMessageEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } is ChatMessageEncryptedFileHeaderEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } @@ -687,11 +781,15 @@ object LocalCache { is BadgeAwardEvent -> event.awardDefinition().map { getOrCreateAddressableNote(it) } is PrivateDmEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } is RepostEvent -> - event.boostedEventIds().mapNotNull { checkGetOrCreateNote(it) } + - event.boostedAddresses().map { getOrCreateAddressableNote(it) } + listOfNotNull( + event.boostedEventId()?.let { checkGetOrCreateNote(it) }, + event.boostedAddress()?.let { getOrCreateAddressableNote(it) }, + ) is GenericRepostEvent -> - event.boostedEventIds().mapNotNull { checkGetOrCreateNote(it) } + - event.boostedAddresses().map { getOrCreateAddressableNote(it) } + listOfNotNull( + event.boostedEventId()?.let { checkGetOrCreateNote(it) }, + event.boostedAddress()?.let { getOrCreateAddressableNote(it) }, + ) is CommunityPostApprovalEvent -> event.approvedEvents().mapNotNull { checkGetOrCreateNote(it) } + event.approvedAddresses().map { getOrCreateAddressableNote(it) } @@ -714,364 +812,413 @@ object LocalCache { is TorrentCommentEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } - is DraftEvent -> { - event.mapTaggedEventId { checkGetOrCreateNote(it) } + event.mapTaggedAddress { checkGetOrCreateAddressableNote(it) } - } - - else -> emptyList() + else -> emptyList() } fun consume( event: PollNoteEvent, - relay: Relay?, - ) = consumeRegularEvent(event, relay) + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) private fun consume( event: LiveActivitiesEvent, - relay: Relay?, - ) { + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { val version = getOrCreateNote(event.id) val note = getOrCreateAddressableNote(event.address()) val author = getOrCreateUser(event.pubKey) - if (version.event == null) { - version.loadEvent(event, author, emptyList()) - version.moveAllReferencesTo(note) - } + val isVerified = + if (version.event == null && (wasVerified || justVerify(event))) { + version.loadEvent(event, author, emptyList()) + version.moveAllReferencesTo(note) + true + } else { + wasVerified + } - if (note.event?.id == event.id) return + if (note.event?.id == event.id) return false - if (event.createdAt > (note.createdAt() ?: 0)) { + if (event.createdAt > (note.createdAt() ?: 0) && (isVerified || justVerify(event))) { note.loadEvent(event, author, emptyList()) - val channel = getOrCreateChannel(note.idHex) { LiveActivitiesChannel(note.address) } as? LiveActivitiesChannel + val channel = getOrCreateLiveChannel(note.address) if (relay != null) { - channel?.addRelay(relay) + channel.addRelay(relay) } val creator = event.host()?.let { checkGetOrCreateUser(it.pubKey) } ?: author - channel?.updateChannelInfo(creator, event, event.createdAt) + channel.updateChannelInfo(creator, event) - refreshObservers(note) + refreshNewNoteObservers(note) + + return true } + + return false } fun consume( event: MuteListEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: CommunityListEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: GitRepositoryEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: ChannelListEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: BlossomServersEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: FileServersEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: PeopleListEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + fun consume( + event: EphemeralChatListEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + fun consume( + event: FollowListEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: AdvertisedRelayListEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: ChatMessageRelayListEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: PrivateOutboxRelayListEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + private fun consume( + event: HashtagListEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + private fun consume( + event: GeohashListEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: SearchRelayListEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + private fun consume( + event: BlockedRelayListEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + private fun consume( + event: TrustedRelayListEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + private fun consume( + event: ProxyRelayListEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + private fun consume( + event: IndexerRelayListEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + private fun consume( + event: BroadcastRelayListEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: CommunityDefinitionEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: EmojiPackSelectionEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: EmojiPackEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: ClassifiedsEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: PinListEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: RelaySetEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: AudioTrackEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: VideoVerticalEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: VideoHorizontalEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: StatusEvent, - relay: Relay?, - ) { + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { val version = getOrCreateNote(event.id) val note = getOrCreateAddressableNote(event.address()) val author = getOrCreateUser(event.pubKey) - if (version.event == null) { - version.loadEvent(event, author, emptyList()) - version.moveAllReferencesTo(note) - } + val isVerified = + if (version.event == null && (wasVerified || justVerify(event))) { + version.loadEvent(event, author, emptyList()) + version.moveAllReferencesTo(note) + true + } else { + wasVerified + } // Already processed this event. - if (note.event?.id == event.id) return + if (note.event?.id == event.id) return false - if (event.createdAt > (note.createdAt() ?: 0)) { + if (event.createdAt > (note.createdAt() ?: 0) && (isVerified || justVerify(event))) { note.loadEvent(event, author, emptyList()) - author.liveSet?.innerStatuses?.invalidateData() + author.flowSet?.statuses?.invalidateData() - refreshObservers(note) + refreshNewNoteObservers(note) + + return true } + + return false } fun consume( event: RelationshipStatusEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: OtsEvent, - relay: Relay?, - ) { + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { val version = getOrCreateNote(event.id) val author = getOrCreateUser(event.pubKey) // Already processed this event. - if (version.event?.id == event.id) return + if (version.event?.id == event.id) return false - if (version.event == null) { - version.loadEvent(event, author, emptyList()) - version.liveSet?.innerOts?.invalidateData() + if (wasVerified || justVerify(event)) { + if (version.event == null) { + version.loadEvent(event, author, emptyList()) + version.flowSet?.ots?.invalidateData() + } + + refreshNewNoteObservers(version) + return true } - refreshObservers(version) + return false } fun consume( event: BadgeDefinitionEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) - fun consume(event: BadgeProfilesEvent) { - val version = getOrCreateNote(event.id) - val note = getOrCreateAddressableNote(event.address()) - val author = getOrCreateUser(event.pubKey) - - if (version.event == null) { - version.loadEvent(event, author, emptyList()) - version.moveAllReferencesTo(note) - } - - // Already processed this event. - if (note.event?.id == event.id) return - - val replyTo = computeReplyTo(event) - - if (event.createdAt > (note.createdAt() ?: 0)) { - note.loadEvent(event, author, replyTo) - - refreshObservers(note) - } - } + fun consume( + event: BadgeProfilesEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: BadgeAwardEvent, - relay: Relay?, - ) = consumeRegularEvent(event, relay) + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) - private fun comsume( + private fun consume( event: NNSEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: AppDefinitionEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: CalendarEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: CalendarDateSlotEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: CalendarTimeSlotEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: CalendarRSVPEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consumeBaseReplaceable( event: BaseAddressableEvent, - relay: Relay?, - ) { + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { val version = getOrCreateNote(event.id) - val note = getOrCreateAddressableNote(event.address()) + val replaceableNote = getOrCreateAddressableNote(event.address()) val author = getOrCreateUser(event.pubKey) - val replyTos = computeReplyTo(event) - - if (version.event == null) { - version.loadEvent(event, author, emptyList()) - version.moveAllReferencesTo(note) - } + val isVerified = + if (version.event == null && (wasVerified || justVerify(event))) { + version.loadEvent(event, author, emptyList()) + version.moveAllReferencesTo(replaceableNote) + true + } else { + wasVerified + } if (relay != null) { author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) + replaceableNote.addRelay(relay) } // Already processed this event. - if (note.event?.id == event.id) return + if (replaceableNote.event?.id == event.id) return isVerified - if (event.createdAt > (note.createdAt() ?: 0)) { - note.loadEvent(event, author, replyTos) + if (event.createdAt > (replaceableNote.createdAt() ?: 0) && (isVerified || justVerify(event))) { + // clear index from previous tags + replaceableNote.replyTo?.forEach { + it.removeNote(replaceableNote) + } - refreshObservers(note) + replaceableNote.loadEvent(event, author, computeReplyTo(event)) + + refreshNewNoteObservers(replaceableNote) + + return true + } else { + return false } } fun consume( event: AppRecommendationEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: AppSpecificDataEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: PrivateDmEvent, - relay: Relay?, - ): Note { + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) + + fun consume( + event: DeletionEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { val note = getOrCreateNote(event.id) val author = getOrCreateUser(event.pubKey) @@ -1081,91 +1228,81 @@ object LocalCache { } // Already processed this event. - if (note.event != null) return note + if (note.event != null) return false - val recipient = event.verifiedRecipientPubKey()?.let { getOrCreateUser(it) } + if (wasVerified || justVerify(event)) { + note.loadEvent(event, author, emptyList()) - // Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}") + if (deletionIndex.add(event, wasVerified)) { + event + .deleteEvents() + .mapNotNull { getNoteIfExists(it) } + .forEach { deleteNote -> + val deleteNoteEvent = deleteNote.event + if (deleteNoteEvent is AddressableEvent) { + val addressableNote = getAddressableNoteIfExists(deleteNoteEvent.addressTag()) + if (addressableNote?.author?.pubkeyHex == event.pubKey && (addressableNote.createdAt() ?: 0) <= event.createdAt) { + // Counts the replies + deleteNote(addressableNote) - val repliesTo = computeReplyTo(event) + addressables.remove(addressableNote.address) + } + } - note.loadEvent(event, author, repliesTo) - - if (recipient != null) { - author.addMessage(recipient, note) - recipient.addMessage(author, note) - } - - refreshObservers(note) - - return note - } - - fun consume(event: DeletionEvent) { - if (deletionIndex.add(event)) { - var deletedAtLeastOne = false - - event - .deleteEvents() - .mapNotNull { getNoteIfExists(it) } - .forEach { deleteNote -> - val deleteNoteEvent = deleteNote.event - if (deleteNoteEvent is AddressableEvent) { - val addressableNote = getAddressableNoteIfExists(deleteNoteEvent.addressTag()) - if (addressableNote?.author?.pubkeyHex == event.pubKey && (addressableNote.createdAt() ?: 0) <= event.createdAt) { - // Counts the replies - deleteNote(addressableNote) - - addressables.remove(addressableNote.idHex) - - deletedAtLeastOne = true + // must be the same author + if (deleteNote.author?.pubkeyHex == event.pubKey) { + // reverts the add + deleteNote(deleteNote) } } - // must be the same author - if (deleteNote.author?.pubkeyHex == event.pubKey) { - // reverts the add - deleteNote(deleteNote) + val addressList = event.deleteAddressIds() + val addressSet = addressList.toSet() - deletedAtLeastOne = true + addressList + .mapNotNull { getAddressableNoteIfExists(it) } + .forEach { deleteNote -> + // must be the same author + if (deleteNote.author?.pubkeyHex == event.pubKey && (deleteNote.createdAt() ?: 0) <= event.createdAt) { + // Counts the replies + deleteNote(deleteNote) + + addressables.remove(deleteNote.address) + } } - } - val addressList = event.deleteAddressIds() - val addressSet = addressList.toSet() - - addressList - .mapNotNull { getAddressableNoteIfExists(it) } - .forEach { deleteNote -> - // must be the same author - if (deleteNote.author?.pubkeyHex == event.pubKey && (deleteNote.createdAt() ?: 0) <= event.createdAt) { - // Counts the replies - deleteNote(deleteNote) - - addressables.remove(deleteNote.idHex) - - deletedAtLeastOne = true - } - } - - notes.forEach { key, note -> - val noteEvent = note.event - if (noteEvent is AddressableEvent && noteEvent.addressTag() in addressSet) { - if (noteEvent.pubKey == event.pubKey && noteEvent.createdAt <= event.createdAt) { - deleteNote(note) - deletedAtLeastOne = true + notes.forEach { key, note -> + val noteEvent = note.event + if (noteEvent is AddressableEvent && noteEvent.addressTag() in addressSet) { + if (noteEvent.pubKey == event.pubKey && noteEvent.createdAt <= event.createdAt) { + deleteNote(note) + } } } } - if (deletedAtLeastOne) { - val note = Note(event.id) - note.loadEvent(event, getOrCreateUser(event.pubKey), emptyList()) - refreshObservers(note) - } + refreshNewNoteObservers(note) + + return true + } else { + return false } } + fun getAnyChannel(note: Note): Channel? = note.event?.let { getAnyChannel(it) } + + fun getAnyChannel(noteEvent: Event): Channel? = + when (noteEvent) { + is ChannelCreateEvent -> getPublicChatChannelIfExists(noteEvent.id) + is ChannelMetadataEvent -> noteEvent.channelId()?.let { getPublicChatChannelIfExists(it) } + is ChannelMessageEvent -> noteEvent.channelId()?.let { getPublicChatChannelIfExists(it) } + is LiveActivitiesChatMessageEvent -> noteEvent.activityAddress()?.let { getLiveActivityChannelIfExists(it) } + is LiveActivitiesEvent -> getLiveActivityChannelIfExists(noteEvent.address()) + is EphemeralChatEvent -> noteEvent.roomId()?.let { getEphemeralChatChannelIfExists(it) } + else -> null + } + + @Suppress("DEPRECATION") private fun deleteNote(deleteNote: Note) { val deletedEvent = deleteNote.event @@ -1180,158 +1317,171 @@ object LocalCache { // Counts the replies deleteNote.replyTo?.forEach { masterNote -> - masterNote.removeReply(deleteNote) - masterNote.removeBoost(deleteNote) - masterNote.removeReaction(deleteNote) - masterNote.removeZap(deleteNote) - masterNote.removeZapPayment(deleteNote) - masterNote.removeReport(deleteNote) + masterNote.removeNote(deleteNote) } - deleteNote.channelHex()?.let { getChannelIfExists(it)?.removeNote(deleteNote) } + deleteNote.inGatherers?.forEach { it.removeNote(deleteNote) } - (deletedEvent as? LiveActivitiesChatMessageEvent)?.activity()?.let { - getChannelIfExists(it.toTag())?.removeNote(deleteNote) - } + getAnyChannel(deleteNote)?.removeNote(deleteNote) (deletedEvent as? TorrentCommentEvent)?.torrentIds()?.let { getNoteIfExists(it)?.removeReply(deleteNote) } - if (deletedEvent is PrivateDmEvent) { - val author = deleteNote.author - val recipient = - deletedEvent.verifiedRecipientPubKey()?.let { - checkGetOrCreateUser(it) - } - - if (recipient != null && author != null) { - author.removeMessage(recipient, deleteNote) - recipient.removeMessage(author, deleteNote) - } - } - - if (deletedEvent is DraftEvent) { - deletedEvent.allCache().forEach { - it?.let { - deindexDraftAsRealEvent(deleteNote, it) - } - } - } + notes.remove(deleteNote.idHex) if (deletedEvent is WrappedEvent) { deleteWraps(deletedEvent) } deleteNote.clearFlow() - deleteNote.clearLive() - notes.remove(deleteNote.idHex) + refreshDeletedNoteObservers(deleteNote) } fun deleteWraps(event: WrappedEvent) { - event.host?.let { + event.host?.let { hostStub -> // seal - getNoteIfExists(it.id)?.let { - val noteEvent = it.event + getNoteIfExists(hostStub.id)?.let { hostNote -> + val noteEvent = hostNote.event if (noteEvent is WrappedEvent) { deleteWraps(noteEvent) } - it.clearFlow() - it.clearLive() + hostNote.clearFlow() + refreshDeletedNoteObservers(hostNote) } - notes.remove(it.id) + notes.remove(hostStub.id) } } - fun consume(event: RepostEvent) { + fun consume( + event: RepostEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { val note = getOrCreateNote(event.id) // Already processed this event. - if (note.event != null) return + if (note.event != null) return false - // Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} - // ${formattedDateTime(event.createdAt)}") + if (wasVerified || justVerify(event)) { + val author = getOrCreateUser(event.pubKey) + val repliesTo = computeReplyTo(event) - val author = getOrCreateUser(event.pubKey) - val repliesTo = computeReplyTo(event) + note.loadEvent(event, author, repliesTo) - note.loadEvent(event, author, repliesTo) + // Counts the replies + repliesTo.forEach { it.addBoost(note) } - // Counts the replies - repliesTo.forEach { it.addBoost(note) } + event.containedPost()?.let { + justConsumeAndUpdateIndexes(it, relay, false) + } - refreshObservers(note) + refreshNewNoteObservers(note) + + return true + } + return false } - fun consume(event: GenericRepostEvent) { + fun consume( + event: GenericRepostEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { val note = getOrCreateNote(event.id) // Already processed this event. - if (note.event != null) return + if (note.event != null) return false - // Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} - // ${formattedDateTime(event.createdAt)}") + if (wasVerified || justVerify(event)) { + val author = getOrCreateUser(event.pubKey) + val repliesTo = computeReplyTo(event) - val author = getOrCreateUser(event.pubKey) - val repliesTo = computeReplyTo(event) + note.loadEvent(event, author, repliesTo) - note.loadEvent(event, author, repliesTo) + // Counts the replies + repliesTo.forEach { it.addBoost(note) } - // Counts the replies - repliesTo.forEach { it.addBoost(note) } + event.containedPost()?.let { + justConsumeAndUpdateIndexes(it, relay, false) + } - refreshObservers(note) + refreshNewNoteObservers(note) + + return true + } + + return false } - fun consume(event: CommunityPostApprovalEvent) { + fun consume( + event: CommunityPostApprovalEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { val note = getOrCreateNote(event.id) // Already processed this event. - if (note.event != null) return + if (note.event != null) return false - // Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} - // ${formattedDateTime(event.createdAt)}") + if (wasVerified || justVerify(event)) { + val author = getOrCreateUser(event.pubKey) - val author = getOrCreateUser(event.pubKey) + val communities = event.communityAddresses() + val eventsApproved = computeReplyTo(event) - val communities = event.communityAddresses() - val eventsApproved = computeReplyTo(event) + val repliesTo = communities.map { getOrCreateAddressableNote(it) } - val repliesTo = communities.map { getOrCreateAddressableNote(it) } + note.loadEvent(event, author, eventsApproved) - note.loadEvent(event, author, eventsApproved) + // Counts the replies + repliesTo.forEach { it.addBoost(note) } - // Counts the replies - repliesTo.forEach { it.addBoost(note) } + event.containedPost()?.let { + justConsumeAndUpdateIndexes(it, relay, false) + } - refreshObservers(note) + refreshNewNoteObservers(note) + + return true + } + + return false } - fun consume(event: ReactionEvent) { + fun consume( + event: ReactionEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { val note = getOrCreateNote(event.id) // Already processed this event. - if (note.event != null) return + if (note.event != null) return true - val author = getOrCreateUser(event.pubKey) - val repliesTo = computeReplyTo(event) + if (wasVerified || justVerify(event)) { + val author = getOrCreateUser(event.pubKey) + val repliesTo = computeReplyTo(event) - note.loadEvent(event, author, repliesTo) + note.loadEvent(event, author, repliesTo) - // Log.d("RE", "New Reaction ${event.content} (${notes.size},${users.size}) - // ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") + repliesTo.forEach { it.addReaction(note) } - repliesTo.forEach { it.addReaction(note) } + refreshNewNoteObservers(note) - refreshObservers(note) + return true + } + + return false } fun consume( event: ReportEvent, - relay: Relay?, - ) { + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { val note = getOrCreateNote(event.id) val author = getOrCreateUser(event.pubKey) @@ -1341,252 +1491,289 @@ object LocalCache { } // Already processed this event. - if (note.event != null) return + if (note.event != null) return false - val mentions = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.pubkey) } - val repliesTo = computeReplyTo(event) + if (wasVerified || justVerify(event)) { + val mentions = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.pubkey) } + val repliesTo = computeReplyTo(event) - note.loadEvent(event, author, repliesTo) + note.loadEvent(event, author, repliesTo) - // Log.d("RP", "New Report ${event.content} by ${note.author?.toBestDisplayName()} - // ${formattedDateTime(event.createdAt)}") - // Adds notifications to users. - if (repliesTo.isEmpty()) { - mentions.forEach { it.addReport(note) } - } else { - repliesTo.forEach { it.addReport(note) } + // Log.d("RP", "New Report ${event.content} by ${note.author?.toBestDisplayName()} + // ${formattedDateTime(event.createdAt)}") + // Adds notifications to users. + if (repliesTo.isEmpty()) { + mentions.forEach { it.addReport(note) } + } else { + repliesTo.forEach { it.addReport(note) } - mentions.forEach { - // doesn't add to reports, but triggers recounts - it.liveSet?.innerReports?.invalidateData() + mentions.forEach { + // doesn't add to reports, but triggers recounts + it.flowSet?.reports?.invalidateData() + } } + + refreshNewNoteObservers(note) + + return true } - refreshObservers(note) + return false } fun consume( event: ChannelCreateEvent, - relay: Relay?, - ) { + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { // Log.d("MT", "New Event ${event.content} ${event.id.toHex()}") - val oldChannel = getOrCreateChannel(event.id) { PublicChatChannel(it) } + val oldChannel = getOrCreatePublicChatChannel(event.id) val author = getOrCreateUser(event.pubKey) - val note = getOrCreateNote(event.id) - if (note.event == null) { - oldChannel.addNote(note, relay) - note.loadEvent(event, author, emptyList()) - refreshObservers(note) - } + val isVerified = + if (note.event == null && (wasVerified || justVerify(event))) { + oldChannel.addNote(note, relay) + note.loadEvent(event, author, emptyList()) + + refreshNewNoteObservers(note) + true + } else { + wasVerified + } if (event.createdAt <= oldChannel.updatedMetadataAt) { - return // older data, does nothing + return false // older data, does nothing } + if (oldChannel.creator == null || oldChannel.creator == author) { - if (oldChannel is PublicChatChannel) { + if (isVerified || justVerify(event)) { oldChannel.updateChannelInfo(author, event) } } + + return isVerified } fun consume( event: ChannelMetadataEvent, - relay: Relay?, - ) { + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { val channelId = event.channelId() - // Log.d("MT", "New PublicChatMetadata ${event.channelInfo()}") - if (channelId.isNullOrBlank()) return + if (channelId.isNullOrBlank()) return false // new event - val oldChannel = checkGetOrCreateChannel(channelId) ?: return + val oldChannel = checkGetOrCreatePublicChatChannel(channelId) ?: return false val author = getOrCreateUser(event.pubKey) - if (event.createdAt > oldChannel.updatedMetadataAt) { - if (oldChannel is PublicChatChannel) { - oldChannel.updateChannelInfo(author, event) + val isVerified = + if (event.createdAt > oldChannel.updatedMetadataAt) { + if (wasVerified || justVerify(event)) { + oldChannel.updateChannelInfo(author, event) + true + } else { + false + } + } else { + wasVerified } - } else { - // Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} - // ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}") - } val note = getOrCreateNote(event.id) - if (note.event == null) { + if (note.event == null && (isVerified || justVerify(event))) { oldChannel.addNote(note, relay) note.loadEvent(event, author, emptyList()) - refreshObservers(note) + refreshNewNoteObservers(note) } + + return isVerified } fun consume( event: ChannelMessageEvent, - relay: Relay?, - ) { - val channelId = event.channelId() + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { + val channelId = event.channelId() ?: return false - if (channelId.isNullOrBlank()) return + val new = consumeRegularEvent(event, relay, wasVerified) - val channel = checkGetOrCreateChannel(channelId) ?: return + if (new) { + val channel = checkGetOrCreatePublicChatChannel(channelId) + if (channel == null) { + Log.w("LocalCache", "Unable to create public chat channel for event ${event.toJson()}") + return false + } - val note = getOrCreateNote(event.id) - channel.addNote(note, relay) - - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) + val note = getOrCreateNote(event.id) + channel.addNote(note, relay) } - // Already processed this event. - if (note.event != null) return + return new + } - if (antiSpam.isSpam(event, relay)) { - return + fun consume( + event: EphemeralChatEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { + val roomId = event.roomId() ?: return false + + val new = consumeRegularEvent(event, relay, wasVerified) + + if (new) { + val note = getOrCreateNote(event.id) + val channel = getOrCreateEphemeralChannel(roomId) + channel.addNote(note, relay) } - val replyTo = computeReplyTo(event) + return new + } - note.loadEvent(event, author, replyTo) + fun consume( + event: LiveActivitiesChatMessageEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { + val activityAddress = event.activityAddress() ?: return false - // Log.d("CM", "New Chat Note (${note.author?.toBestDisplayName()} ${note.event?.content} - // ${formattedDateTime(event.createdAt)}") + val new = consumeRegularEvent(event, relay, wasVerified) - // Counts the replies - replyTo.forEach { it.addReply(note) } + if (new) { + val channel = getOrCreateLiveChannel(activityAddress) + val note = getOrCreateNote(event.id) + channel.addNote(note, relay) + } - refreshObservers(note) + return new } fun consume( event: CommentEvent, - relay: Relay?, - ) = consumeRegularEvent(event, relay) + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) + @Suppress("UNUSED_PARAMETER") fun consume( - event: LiveActivitiesChatMessageEvent, - relay: Relay?, - ) { - val activityAddress = event.activityAddress() ?: return - - val channel = getOrCreateChannel(activityAddress.toValue()) { LiveActivitiesChannel(activityAddress) } - - val note = getOrCreateNote(event.id) - channel.addNote(note, relay) - - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - if (antiSpam.isSpam(event, relay)) { - return - } - - val replyTo = computeReplyTo(event) - - note.loadEvent(event, author, replyTo) - - // Counts the replies - replyTo.forEach { it.addReply(note) } - - refreshObservers(note) - } + event: ChannelHideMessageEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean = false @Suppress("UNUSED_PARAMETER") - fun consume(event: ChannelHideMessageEvent) {} - - @Suppress("UNUSED_PARAMETER") - fun consume(event: ChannelMuteUserEvent) {} + fun consume( + event: ChannelMuteUserEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean = false fun consume( event: LnZapEvent, - relay: Relay?, - ) { + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { val note = getOrCreateNote(event.id) // Already processed this event. - if (note.event != null) return + if (note.event != null) return false - val zapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) } + if (wasVerified || justVerify(event)) { + val existingZapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) } + if (existingZapRequest == null || existingZapRequest.event == null) { + // tries to add it + event.zapRequest?.let { + justConsumeAndUpdateIndexes(it, relay, false) + } + } - if (zapRequest == null || zapRequest.event !is LnZapRequestEvent) { - Log.e("ZP", "Zap Request not found. Unable to process Zap {${event.toJson()}}") - return + val zapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) } + + if (zapRequest == null || zapRequest.event !is LnZapRequestEvent) { + Log.e("ZP", "Zap Request not found. Unable to process Zap {${event.toJson()}}") + return false + } + + val author = getOrCreateUser(event.pubKey) + val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) } + val repliesTo = computeReplyTo(event) + + note.loadEvent(event, author, repliesTo) + + repliesTo.forEach { it.addZap(zapRequest, note) } + mentions.forEach { it.addZap(zapRequest, note) } + + refreshNewNoteObservers(note) + + return true } - val author = getOrCreateUser(event.pubKey) - val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) } - val repliesTo = computeReplyTo(event) - - note.loadEvent(event, author, repliesTo) - - // Log.d("ZP", "New ZapEvent ${event.content} (${notes.size},${users.size}) - // ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") - - repliesTo.forEach { it.addZap(zapRequest, note) } - mentions.forEach { it.addZap(zapRequest, note) } - - refreshObservers(note) + return false } - fun consume(event: LnZapRequestEvent) { + fun consume( + event: LnZapRequestEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { val note = getOrCreateNote(event.id) // Already processed this event. - if (note.event != null) return + if (note.event != null) return false - val author = getOrCreateUser(event.pubKey) - val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) } - val repliesTo = computeReplyTo(event) + if (wasVerified || justVerify(event)) { + val author = getOrCreateUser(event.pubKey) + val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) } + val repliesTo = computeReplyTo(event) - note.loadEvent(event, author, repliesTo) + note.loadEvent(event, author, repliesTo) - // Log.d("ZP", "New Zap Request ${event.content} (${notes.size},${users.size}) - // ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") + repliesTo.forEach { it.addZap(note, null) } + mentions.forEach { it.addZap(note, null) } - repliesTo.forEach { it.addZap(note, null) } - mentions.forEach { it.addZap(note, null) } + refreshNewNoteObservers(note) - refreshObservers(note) + return true + } + + return false } fun consume( event: AudioHeaderEvent, - relay: Relay?, - ) = consumeRegularEvent(event, relay) + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: FileHeaderEvent, - relay: Relay?, - ) = consumeRegularEvent(event, relay) + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: ProfileGalleryEntryEvent, - relay: Relay?, - ) = consumeRegularEvent(event, relay) + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: FileStorageHeaderEvent, - relay: Relay?, - ) = consumeRegularEvent(event, relay) + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: FhirResourceEvent, - relay: Relay?, - ) = consumeRegularEvent(event, relay) + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: TextNoteModificationEvent, - relay: Relay?, - ) { + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { val note = getOrCreateNote(event.id) val author = getOrCreateUser(event.pubKey) @@ -1596,30 +1783,38 @@ object LocalCache { } // Already processed this event. - if (note.event != null) return + if (note.event != null) return false - note.loadEvent(event, author, emptyList()) + if (wasVerified || justVerify(event)) { + note.loadEvent(event, author, emptyList()) - event.editedNote()?.let { - checkGetOrCreateNote(it.eventId)?.let { editedNote -> - modificationCache.remove(editedNote.idHex) - // must update list of Notes to quickly update the user. - editedNote.liveSet?.innerModifications?.invalidateData() + event.editedNote()?.let { + checkGetOrCreateNote(it.eventId)?.let { editedNote -> + modificationCache.remove(editedNote.idHex) + // must update list of Notes to quickly update the user. + editedNote.flowSet?.edits?.invalidateData() + } } + + refreshNewNoteObservers(note) + + return true } - refreshObservers(note) + return false } fun consume( event: HighlightEvent, - relay: Relay?, - ) = consumeRegularEvent(event, relay) + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: FileStorageEvent, - relay: Relay?, - ) { + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { val note = getOrCreateNote(event.id) val author = getOrCreateUser(event.pubKey) @@ -1628,188 +1823,118 @@ object LocalCache { note.addRelay(relay) } - try { - val cachePath = Amethyst.instance.nip95cache - cachePath.mkdirs() - val file = File(cachePath, event.id) - if (!file.exists()) { - val stream = FileOutputStream(file) - stream.write(event.decode()) - stream.close() - Log.i( - "FileStorageEvent", - "NIP95 File received from ${relay?.url} and saved to disk as $file", - ) + var isVerified = + try { + val cachePath = Amethyst.instance.nip95cache + cachePath.mkdirs() + val file = File(cachePath, event.id) + if (!file.exists() && (wasVerified || justVerify(event))) { + val stream = FileOutputStream(file) + stream.write(event.decode()) + stream.close() + Log.i( + "FileStorageEvent", + "NIP95 File received from $relay and saved to disk as $file", + ) + true + } else { + wasVerified + } + } catch (e: IOException) { + Log.e("FileStorageEvent", "FileStorageEvent save to disk error: " + event.id, e) + wasVerified } - } catch (e: IOException) { - Log.e("FileStorageEvent", "FileStorageEvent save to disk error: " + event.id, e) - } // Already processed this event. - if (note.event != null) return + if (note.event != null) return false - // this is an invalid event. But we don't need to keep the data in memory. - val eventNoData = - FileStorageEvent(event.id, event.pubKey, event.createdAt, event.tags, "", event.sig) + if (isVerified || justVerify(event)) { + // this is an invalid event. But we don't need to keep the data in memory. + val eventNoData = + FileStorageEvent(event.id, event.pubKey, event.createdAt, event.tags, "", event.sig) - note.loadEvent(eventNoData, author, emptyList()) + note.loadEvent(eventNoData, author, emptyList()) - refreshObservers(note) + refreshNewNoteObservers(note) + + return true + } + + return false } private fun consume( event: ChatMessageEvent, - relay: Relay?, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - val recipientsHex = event.groupMembers() - val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet() - - // Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}") - - val repliesTo = computeReplyTo(event) - - note.loadEvent(event, author, repliesTo) - - if (recipients.isNotEmpty()) { - recipients.forEach { - val groupMinusRecipient = recipientsHex.minus(it.pubkeyHex) - - val authorGroup = - if (groupMinusRecipient.isEmpty()) { - // note to self - ChatroomKey(persistentSetOf(it.pubkeyHex)) - } else { - ChatroomKey(groupMinusRecipient.toImmutableSet()) - } - - it.addMessage(authorGroup, note) - } - } - - refreshObservers(note) - } + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) private fun consume( event: ChatMessageEncryptedFileHeaderEvent, - relay: Relay?, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - val recipientsHex = event.groupMembers() - val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet() - - // Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}") - - val repliesTo = computeReplyTo(event) - - note.loadEvent(event, author, repliesTo) - - if (recipients.isNotEmpty()) { - recipients.forEach { - val groupMinusRecipient = recipientsHex.minus(it.pubkeyHex) - - val authorGroup = - if (groupMinusRecipient.isEmpty()) { - // note to self - ChatroomKey(persistentSetOf(it.pubkeyHex)) - } else { - ChatroomKey(groupMinusRecipient.toImmutableSet()) - } - - it.addMessage(authorGroup, note) - } - } - - refreshObservers(note) - } + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: SealedRumorEvent, - relay: Relay?, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - note.loadEvent(event.copyNoContent(), author, emptyList()) - - refreshObservers(note) - } + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: GiftWrapEvent, - relay: Relay?, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - note.loadEvent(event.copyNoContent(), author, emptyList()) - - refreshObservers(note) - } - - fun consume(event: LnZapPaymentRequestEvent) { + fun consume( + event: LnZapPaymentRequestEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { // Does nothing without a response callback. + return true } fun consume( event: LnZapPaymentRequestEvent, zappedNote: Note?, - onResponse: (LnZapPaymentResponseEvent) -> Unit, - ) { + wasVerified: Boolean, + relay: NormalizedRelayUrl?, + onResponse: suspend (LnZapPaymentResponseEvent) -> Unit, + ): Boolean { val note = getOrCreateNote(event.id) val author = getOrCreateUser(event.pubKey) // Already processed this event. - if (note.event != null) return + if (note.event != null) return false - note.loadEvent(event, author, emptyList()) + if (wasVerified || justVerify(event)) { + note.loadEvent(event, author, emptyList()) - zappedNote?.addZapPayment(note, null) + relay?.let { + note.addRelay(relay) + } - awaitingPaymentRequests.put(event.id, Pair(zappedNote, onResponse)) + zappedNote?.addZapPayment(note, null) - refreshObservers(note) + awaitingPaymentRequests.put(event.id, Pair(zappedNote, onResponse)) + + refreshNewNoteObservers(note) + + return true + } + + return false } - fun consume(event: LnZapPaymentResponseEvent) { + fun consume( + event: LnZapPaymentResponseEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { val requestId = event.requestId() - val pair = awaitingPaymentRequests[requestId] ?: return + val pair = awaitingPaymentRequests[requestId] ?: return false val (zappedNote, responseCallback) = pair @@ -1819,19 +1944,29 @@ object LocalCache { val author = getOrCreateUser(event.pubKey) // Already processed this event. - if (note.event != null) return + if (note.event != null) return false - note.loadEvent(event, author, emptyList()) + if (wasVerified || justVerify(event)) { + note.loadEvent(event, author, emptyList()) - requestNote?.let { request -> zappedNote?.addZapPayment(request, note) } + requestNote?.let { request -> zappedNote?.addZapPayment(request, note) } - responseCallback(event) + GlobalScope.launch(Dispatchers.Default) { + responseCallback(event) + } + + return true + } + + return false } fun findUsersStartingWith( username: String, forAccount: Account?, ): List { + if (username.isBlank()) return emptyList() + checkNotInMainThread() val key = decodePublicKeyAsHexOrNull(username) @@ -1850,7 +1985,7 @@ object LocalCache { user.pubkeyHex.startsWith(username, true) || user.pubkeyNpub().startsWith(username, true) ) && - (forAccount == null || (!forAccount.isHidden(user) && !user.containsAny(forAccount.flowHiddenUsers.value.hiddenWordsCase))) + (forAccount == null || (!forAccount.isHidden(user) && !user.containsAny(forAccount.hiddenUsers.flow.value.hiddenWordsCase))) } return finds.sortedWith( @@ -1880,10 +2015,12 @@ object LocalCache { fun findNotesStartingWith( text: String, - forAccount: Account, + hiddenUsers: HiddenUsersState, ): List { checkNotInMainThread() + if (text.isBlank()) return emptyList() + val key = decodeEventIdAsHexOrNull(text) if (key != null) { @@ -1901,15 +2038,11 @@ object LocalCache { if (note.event?.tags?.tagValueContains(text, true) == true || note.idHex.startsWith(text, true) ) { - if (!note.isHiddenFor(forAccount.flowHiddenUsers.value)) { - return@filter true - } else { - return@filter false - } + return@filter !note.isHiddenFor(hiddenUsers.flow.value) } if (note.event?.isContentEncoded() == false) { - if (!note.isHiddenFor(forAccount.flowHiddenUsers.value)) { + if (!note.isHiddenFor(hiddenUsers.flow.value)) { return@filter note.event?.content?.contains(text, true) ?: false } else { return@filter false @@ -1926,15 +2059,11 @@ object LocalCache { if (addressable.event?.tags?.tagValueContains(text, true) == true || addressable.idHex.startsWith(text, true) ) { - if (!addressable.isHiddenFor(forAccount.flowHiddenUsers.value)) { - return@filter true - } else { - return@filter false - } + return@filter !addressable.isHiddenFor(hiddenUsers.flow.value) } if (addressable.event?.isContentEncoded() == false) { - if (!addressable.isHiddenFor(forAccount.flowHiddenUsers.value)) { + if (!addressable.isHiddenFor(hiddenUsers.flow.value)) { return@filter addressable.event?.content?.contains(text, true) ?: false } else { return@filter false @@ -1945,18 +2074,44 @@ object LocalCache { } } - fun findChannelsStartingWith(text: String): List { - checkNotInMainThread() + fun findPublicChatChannelsStartingWith(text: String): List { + if (text.isBlank()) return emptyList() val key = decodeEventIdAsHexOrNull(text) - if (key != null && getChannelIfExists(key) != null) { - return listOfNotNull(getChannelIfExists(key)) + if (key != null) { + getPublicChatChannelIfExists(key)?.let { + return listOf(it) + } } - return channels.filter { _, channel -> - channel.anyNameStartsWith(text) || - channel.idHex.startsWith(text, true) || - channel.idNote().startsWith(text, true) + return publicChatChannels.filter { _, channel -> + channel.anyNameStartsWith(text) + } + } + + fun findEphemeralChatChannelsStartingWith(text: String): List { + if (text.isBlank()) return emptyList() + + return ephemeralChannels.filter { _, channel -> + channel.anyNameStartsWith(text) + } + } + + fun findLiveActivityChannelsStartingWith(text: String): List { + if (text.isBlank()) return emptyList() + + try { + val parsed = Nip19Parser.uriToRoute(text)?.entity + if (parsed is NAddress && parsed.kind == LiveActivitiesEvent.KIND) { + return listOf(getOrCreateLiveChannel(parsed.address())) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + + return liveChatChannels.filter { _, channel -> + channel.anyNameStartsWith(text) } } @@ -1992,7 +2147,7 @@ object LocalCache { suspend fun findEarliestOtsForNote( note: Note, - resolverBuilder: () -> OtsResolver, + resolverBuilder: OtsResolverBuilder, ): Long? { checkNotInMainThread() @@ -2020,7 +2175,7 @@ object LocalCache { suspend fun findLatestModificationForNote(note: Note): List { checkNotInMainThread() - val originalAuthor = note.author?.pubkeyHex ?: return emptyList() + val noteAuthor = note.author ?: return emptyList() modificationCache[note.idHex]?.let { return it @@ -2033,7 +2188,7 @@ object LocalCache { .filter { _, item -> val noteEvent = item.event - noteEvent is TextNoteModificationEvent && note.author == item.author && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time) + noteEvent is TextNoteModificationEvent && noteAuthor == item.author && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time) }.sortedWith(compareBy({ it.createdAt() }, { it.idHex })) modificationCache.put(note.idHex, newNotes) @@ -2041,11 +2196,13 @@ object LocalCache { return newNotes } - fun cleanObservers() { - notes.forEach { _, it -> it.clearLive() } - addressables.forEach { _, it -> it.clearLive() } - users.forEach { _, it -> it.clearLive() } + fun cleanMemory() { + notes.cleanUp() + addressables.cleanUp() + users.cleanUp() + } + fun cleanObservers() { notes.forEach { _, it -> it.clearFlow() } addressables.forEach { _, it -> it.clearFlow() } users.forEach { _, it -> it.clearFlow() } @@ -2067,38 +2224,86 @@ object LocalCache { } } - fun pruneOldAndHiddenMessages(account: Account) { - checkNotInMainThread() + fun pruneHiddenMessagesChannel( + channel: Channel, + account: Account, + ) { + val toBeRemoved = channel.pruneHiddenMessages(account) - channels.forEach { _, channel -> - val toBeRemoved = channel.pruneOldAndHiddenMessages(account) + val childrenToBeRemoved = mutableListOf() - val childrenToBeRemoved = mutableListOf() + toBeRemoved.forEach { + removeFromCache(it) - toBeRemoved.forEach { - removeFromCache(it) - - childrenToBeRemoved.addAll(it.removeAllChildNotes()) - } - - removeFromCache(childrenToBeRemoved) - - if (toBeRemoved.size > 100 || channel.notes.size() > 100) { - println( - "PRUNE: ${toBeRemoved.size} messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept", - ) - } + childrenToBeRemoved.addAll(it.removeAllChildNotes()) } - users.forEach { _, user -> - user.privateChatrooms.values.map { - val toBeRemoved = it.pruneMessagesToTheLatestOnly() + removeFromCache(childrenToBeRemoved) + + if (toBeRemoved.size > 100 || channel.notes.size() > 100) { + println( + "PRUNE: ${toBeRemoved.size} hidden messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept", + ) + } + } + + fun pruneHiddenMessages(account: Account) { + ephemeralChannels.forEach { _, channel -> + pruneHiddenMessagesChannel(channel, account) + } + + liveChatChannels.forEach { _, channel -> + pruneHiddenMessagesChannel(channel, account) + } + + publicChatChannels.forEach { _, channel -> + pruneHiddenMessagesChannel(channel, account) + } + } + + fun pruneOldMessagesChannel(channel: Channel) { + val toBeRemoved = channel.pruneOldMessages() + + val childrenToBeRemoved = mutableListOf() + + toBeRemoved.forEach { + removeFromCache(it) + + childrenToBeRemoved.addAll(it.removeAllChildNotes()) + } + + removeFromCache(childrenToBeRemoved) + + if (toBeRemoved.size > 100 || channel.notes.size() > 100) { + println( + "PRUNE: ${toBeRemoved.size} old messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept", + ) + } + } + + fun pruneOldMessages() { + checkNotInMainThread() + + ephemeralChannels.forEach { _, channel -> + pruneOldMessagesChannel(channel) + } + + liveChatChannels.forEach { _, channel -> + pruneOldMessagesChannel(channel) + } + + publicChatChannels.forEach { _, channel -> + pruneOldMessagesChannel(channel) + } + + chatroomList.forEach { userHex, room -> + room.rooms.map { key, chatroom -> + val toBeRemoved = chatroom.pruneMessagesToTheLatestOnly() val childrenToBeRemoved = mutableListOf() toBeRemoved.forEach { - // TODO: NEED TO TEST IF WRAPS COME BACK WHEN NEEDED BEFORE ACTIVATING - // childrenToBeRemoved.addAll(removeIfWrap(it)) + childrenToBeRemoved.addAll(removeIfWrap(it)) removeFromCache(it) childrenToBeRemoved.addAll(it.removeAllChildNotes()) @@ -2108,7 +2313,7 @@ object LocalCache { if (toBeRemoved.size > 1) { println( - "PRUNE: ${toBeRemoved.size} private messages from ${user.toBestDisplayName()} to ${it.authors.joinToString(", ") { it.toBestDisplayName() }} removed. ${it.roomMessages.size} kept", + "PRUNE: ${toBeRemoved.size} private messages from $userHex to ${key.users.joinToString()} removed. ${chatroom.messages.size} kept", ) } } @@ -2139,7 +2344,7 @@ object LocalCache { val noteEvent = note.event if (noteEvent is AddressableEvent) { noteEvent.createdAt < - (addressables.get(noteEvent.aTag().toTag())?.event?.createdAt ?: 0) + (addressables.get(noteEvent.address())?.event?.createdAt ?: 0) } else { false } @@ -2148,7 +2353,7 @@ object LocalCache { val childrenToBeRemoved = mutableListOf() toBeRemoved.forEach { - val newerVersion = (it.event as? AddressableEvent)?.aTag()?.toTag()?.let { tag -> addressables.get(tag) } + val newerVersion = (it.event as? AddressableEvent)?.address()?.let { tag -> addressables.get(tag) } if (newerVersion != null) { it.moveAllReferencesTo(newerVersion) } @@ -2177,8 +2382,8 @@ object LocalCache { note.event is ReportEvent || note.event is GenericRepostEvent ) && - note.replyTo?.any { it.liveSet?.isInUse() == true } != true && - note.liveSet?.isInUse() != true && + note.replyTo?.any { it.flowSet?.isInUse() == true } != true && + note.flowSet?.isInUse() != true && // don't delete if observing. note.author?.pubkeyHex !in accounts && @@ -2196,12 +2401,6 @@ object LocalCache { removeFromCache(childrenToBeRemoved) - toBeRemoved.forEach { - it.replyTo?.forEach { masterNote -> - masterNote.clearEOSE() // allows reloading of these events - } - } - if (toBeRemoved.size > 1) { println("PRUNE: ${toBeRemoved.size} thread replies removed.") } @@ -2209,42 +2408,37 @@ object LocalCache { private fun removeFromCache(note: Note) { note.replyTo?.forEach { masterNote -> - masterNote.removeReply(note) - masterNote.removeBoost(note) - masterNote.removeReaction(note) - masterNote.removeZap(note) - masterNote.removeReport(note) - masterNote.clearEOSE() // allows reloading of these events if needed + masterNote.removeNote(note) } + note.inGatherers?.forEach { it.removeNote(note) } + val noteEvent = note.event if (noteEvent is LnZapEvent) { noteEvent.zappedAuthor().forEach { val author = getUserIfExists(it) author?.removeZap(note) - author?.clearEOSE() } } if (noteEvent is LnZapRequestEvent) { noteEvent.zappedAuthor().mapNotNull { val author = getUserIfExists(it) author?.removeZap(note) - author?.clearEOSE() } } if (noteEvent is ReportEvent) { noteEvent.reportedAuthor().mapNotNull { val author = getUserIfExists(it.pubkey) author?.removeReport(note) - author?.clearEOSE() } } note.clearFlow() - note.clearLive() notes.remove(note.idHex) + + refreshDeletedNoteObservers(note) } fun removeFromCache(nextToBeRemoved: List) { @@ -2271,18 +2465,16 @@ object LocalCache { } } - fun pruneHiddenMessages(account: Account) { + fun pruneHiddenEvents(account: Account) { checkNotInMainThread() val childrenToBeRemoved = mutableListOf() val toBeRemoved = - account.liveHiddenUsers.value - ?.hiddenUsers - ?.map { userHex -> + account.hiddenUsers.flow.value.hiddenUsers + .map { userHex -> (notes.filter { _, it -> it.event?.pubKey == userHex } + addressables.filter { _, it -> it.event?.pubKey == userHex }).toSet() - }?.flatten() - ?: emptyList() + }.flatten() toBeRemoved.forEach { removeFromCache(it) @@ -2301,7 +2493,7 @@ object LocalCache { users.forEach { _, user -> if ( user.pubkeyHex !in loggedIn && - (user.liveSet == null || user.liveSet?.isInUse() == false) && + (user.flowSet == null || user.flowSet?.isInUse() == false) && user.latestContactList != null ) { user.latestContactList = null @@ -2312,21 +2504,32 @@ object LocalCache { println("PRUNE: $removingContactList contact lists") } + override fun markAsSeen( + eventId: String, + relay: NormalizedRelayUrl, + ) { + val note = getNoteIfExists(eventId) + + note?.event?.let { noteEvent -> + if (noteEvent is AddressableEvent) { + getAddressableNoteIfExists(noteEvent.aTag().toTag())?.addRelay(relay) + } + } + + note?.addRelay(relay) + } + // Observers line up here. val live: LocalCacheFlow = LocalCacheFlow() - private fun refreshObservers(newNote: Note) { - updateObservables(newNote.event as Event) - live.invalidateData(newNote) + private fun refreshNewNoteObservers(newNote: Note) { + val event = newNote.event as Event + updateObservables(event) + live.newNote(newNote) } - fun verifyAndConsume( - event: Event, - relay: Relay?, - ) { - if (justVerify(event)) { - justConsume(event, relay) - } + private fun refreshDeletedNoteObservers(newNote: Note) { + live.removedNote(newNote) } fun justVerify(event: Event): Boolean { @@ -2337,7 +2540,7 @@ object LocalCache { event.checkSignature() } catch (e: Exception) { if (e is CancellationException) throw e - Log.w("Event failed retest ${event.kind}", (e.message ?: "") + event.toJson()) + Log.w("Event Verification Failed", "Kind: ${event.kind} from ${dateFormatter(event.createdAt, "", "")} with message ${e.message}") } false } else { @@ -2347,324 +2550,349 @@ object LocalCache { fun consume( event: DraftEvent, - relay: Relay?, - ) { + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { if (!event.isDeleted()) { - consumeBaseReplaceable(event, relay) - - event.allCache().forEach { - it?.let { - indexDraftAsRealEvent(event, it) - } + if (consumeBaseReplaceable(event, relay, wasVerified)) { + return true } + } else { + // passes to the AccountViewModel for further delete. + val note = Note(event.id) + note.loadEvent(event, getOrCreateUser(event.pubKey), emptyList()) + relay?.let { note.addRelay(it) } + refreshNewNoteObservers(note) + } + + return false + } + + fun consume(nip19: Entity) { + when (nip19) { + is NSec -> getOrCreateUser(nip19.toPubKeyHex()) + is NPub -> getOrCreateUser(nip19.hex) + is NProfile -> { + nip19.relay.forEach { relayHint -> + relayHints.addKey(nip19.hex, relayHint) + } + getOrCreateUser(nip19.hex) + } + is NNote -> { + getOrCreateNote(nip19.hex) + } + is NEvent -> { + nip19.relay.forEach { relayHint -> + relayHints.addEvent(nip19.hex, relayHint) + } + getOrCreateNote(nip19.hex) + } + is NEmbed -> { + justConsume(nip19.event, null, false) + } + is NRelay -> {} + is NAddress -> { + val aTag = nip19.aTag() + nip19.relay.forEach { relayHint -> + relayHints.addAddress(aTag, relayHint) + } + getOrCreateAddressableNote(nip19.address()) + } + else -> { } } } - fun indexDraftAsRealEvent( - draftWrap: DraftEvent, - draft: Event, - ) { - val note = getOrCreateAddressableNote(draftWrap.address()) - val author = getOrCreateUser(draftWrap.pubKey) - - when (draft) { - is PrivateDmEvent -> { - draft.verifiedRecipientPubKey()?.let { getOrCreateUser(it) }?.let { recipient -> - author.addMessage(recipient, note) - recipient.addMessage(author, note) - } - } - is ChatMessageEvent -> { - val recipientsHex = draft.groupMembers() - val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet() - - if (recipients.isNotEmpty()) { - recipients.forEach { - val groupMinusRecipient = recipientsHex.minus(it.pubkeyHex) - - val authorGroup = - if (groupMinusRecipient.isEmpty()) { - // note to self - ChatroomKey(persistentSetOf(it.pubkeyHex)) - } else { - ChatroomKey(groupMinusRecipient.toImmutableSet()) - } - - it.addMessage(authorGroup, note) - } - } - } - is ChatMessageEncryptedFileHeaderEvent -> { - val recipientsHex = draft.groupMembers() - val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet() - - if (recipients.isNotEmpty()) { - recipients.forEach { - val groupMinusRecipient = recipientsHex.minus(it.pubkeyHex) - - val authorGroup = - if (groupMinusRecipient.isEmpty()) { - // note to self - ChatroomKey(persistentSetOf(it.pubkeyHex)) - } else { - ChatroomKey(groupMinusRecipient.toImmutableSet()) - } - - it.addMessage(authorGroup, note) - } - } - } - is ChannelMessageEvent -> { - draft.channelId()?.let { channelId -> - checkGetOrCreateChannel(channelId)?.addNote(note, null) - } - } - is LiveActivitiesChatMessageEvent -> { - draft.activityAddress()?.let { channelId -> - checkGetOrCreateChannel(channelId.toValue())?.addNote(note, null) - } - } - is TextNoteEvent -> { - val replyTo = computeReplyTo(draft) - val author = getOrCreateUser(draftWrap.pubKey) - note.loadEvent(draftWrap, author, replyTo) - replyTo.forEach { it.addReply(note) } - } - } - } - - fun deindexDraftAsRealEvent( - draftWrap: Note, - draft: Event, - ) { - val author = draftWrap.author ?: return - - when (draft) { - is PrivateDmEvent -> { - draft.verifiedRecipientPubKey()?.let { getOrCreateUser(it) }?.let { recipient -> - author.removeMessage(recipient, draftWrap) - recipient.removeMessage(author, draftWrap) - } - } - is ChatMessageEvent -> { - val recipientsHex = draft.recipientsPubKey().plus(author.pubkeyHex).toSet() - val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet() - - if (recipients.isNotEmpty()) { - recipients.forEach { - val groupMinusRecipient = recipientsHex.minus(it.pubkeyHex) - - val authorGroup = - if (groupMinusRecipient.isEmpty()) { - // note to self - ChatroomKey(persistentSetOf(it.pubkeyHex)) - } else { - ChatroomKey(groupMinusRecipient.toImmutableSet()) - } - - it.removeMessage(authorGroup, draftWrap) - } - } - } - is ChatMessageEncryptedFileHeaderEvent -> { - val recipientsHex = draft.groupMembers() - val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet() - - if (recipients.isNotEmpty()) { - recipients.forEach { - val groupMinusRecipient = recipientsHex.minus(it.pubkeyHex) - - val authorGroup = - if (groupMinusRecipient.isEmpty()) { - // note to self - ChatroomKey(persistentSetOf(it.pubkeyHex)) - } else { - ChatroomKey(groupMinusRecipient.toImmutableSet()) - } - - it.removeMessage(authorGroup, draftWrap) - } - } - } - is ChannelMessageEvent -> { - draft.channelId()?.let { channelId -> - checkGetOrCreateChannel(channelId)?.let { channel -> - channel.removeNote(draftWrap) - } - } - } - is TextNoteEvent -> { - val replyTo = computeReplyTo(draft) - replyTo.forEach { it.removeReply(draftWrap) } - } - } - } + fun justConsumeMyOwnEvent(event: Event) = justConsumeAndUpdateIndexes(event, null, true) fun justConsume( event: Event, - relay: Relay?, - ) { + relay: IRelayClient?, + wasVerified: Boolean, + ): Boolean { if (deletionIndex.hasBeenDeleted(event)) { // update relay with deletion event from another. if (relay != null) { - deletionIndex.hasBeenDeletedBy(event)?.let { - Log.d("LocalCache", "Updating ${relay.url} with a Deletion Event ${it.toJson()} because of ${event.toJson()}") - relay.send(it) + deletionIndex.hasBeenDeletedBy(event)?.let { deletionEvent -> + getNoteIfExists(deletionEvent.id)?.let { note -> + if (!note.hasRelay(relay.url)) { + if (isDebug) { + Log.d("LocalCache", "Updating ${relay.url.url} with a Deletion Event ${event.id} ${deletionEvent.id} because of ${event.toJson()} with ${deletionEvent.toJson()}") + } + relay.send(deletionEvent) + note.addRelay(relay.url) + } + } } } - return + return false } if (event is AddressableEvent && relay != null) { // updates relay with a new event. - getAddressableNoteIfExists(event.addressTag())?.let { note -> + getAddressableNoteIfExists(event.address())?.let { note -> note.event?.let { existingEvent -> - if (existingEvent.createdAt > event.createdAt && !note.hasRelay(relay)) { - Log.d("LocalCache", "Updating ${relay.url} with a new version of ${event.toJson()} to ${existingEvent.toJson()}") + if (existingEvent.createdAt > event.createdAt && !note.hasRelay(relay.url) && !deletionIndex.hasBeenDeleted(event)) { + if (isDebug) { + Log.d("LocalCache", "Updating ${relay.url.url} with a new version of ${event.kind} ${event.id} to ${existingEvent.id}") + } + relay.send(existingEvent) + // only send once. + note.addRelay(relay.url) } } } } - checkNotInMainThread() + return justConsumeAndUpdateIndexes(event, relay?.url, wasVerified) + } + private fun justConsumeAndUpdateIndexes( + event: Event, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { + val wasNew = justConsumeInnerInner(event, relay, wasVerified) + + if (wasNew) { + updateHintIndexes(event) + } + + if (relay != null) { + // uses the internal event to avoid reprocessing cached items. + val note = + if (event is AddressableEvent) { + getAddressableNoteIfExists(event.address()) + } else { + getNoteIfExists(event.id) + } + + note?.event?.let { consumedEvent -> + addIncomingRelayAsHintToAllRelatedEvents(consumedEvent, relay) + } + } + + return wasNew + } + + fun addIncomingRelayAsHintToAllRelatedEvents( + event: Event, + relay: NormalizedRelayUrl, + ) { + relayHints.addEvent(event.id, relay) + if (event is AddressableEvent) { + relayHints.addAddress(event.addressTag(), relay) + } + + if (event is EventHintProvider) { + event.linkedEventIds().forEach { + relayHints.addEvent(it, relay) + } + } + if (event is AddressHintProvider) { + event.linkedAddressIds().forEach { + relayHints.addAddress(it, relay) + } + } + if (event is PubKeyHintProvider) { + event.linkedPubKeys().forEach { + relayHints.addKey(it, relay) + } + } + } + + fun updateHintIndexes(event: Event) { + if (event is EventHintProvider) { + event.eventHints().forEach { + relayHints.addEvent(it.eventId, it.relay) + } + } + if (event is AddressHintProvider) { + event.addressHints().forEach { + relayHints.addAddress(it.addressId, it.relay) + } + } + if (event is PubKeyHintProvider) { + event.pubKeyHints().forEach { + relayHints.addKey(it.pubkey, it.relay) + } + } + } + + @Suppress("DEPRECATION") + private fun justConsumeInnerInner( + event: Event, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean = try { when (event) { - is AdvertisedRelayListEvent -> consume(event, relay) - is AppDefinitionEvent -> consume(event, relay) - is AppRecommendationEvent -> consume(event, relay) - is AppSpecificDataEvent -> consume(event, relay) - is AudioHeaderEvent -> consume(event, relay) - is AudioTrackEvent -> consume(event, relay) - is BadgeAwardEvent -> consume(event, relay) - is BadgeDefinitionEvent -> consume(event, relay) - is BadgeProfilesEvent -> consume(event) - is BlossomServersEvent -> consume(event, relay) - is BookmarkListEvent -> consume(event) - is CalendarEvent -> consume(event, relay) - is CalendarDateSlotEvent -> consume(event, relay) - is CalendarTimeSlotEvent -> consume(event, relay) - is CalendarRSVPEvent -> consume(event, relay) - is ChannelCreateEvent -> consume(event, relay) - is ChannelListEvent -> consume(event, relay) - is ChannelHideMessageEvent -> consume(event) - is ChannelMessageEvent -> consume(event, relay) - is ChannelMetadataEvent -> consume(event, relay) - is ChannelMuteUserEvent -> consume(event) - is ChatMessageEncryptedFileHeaderEvent -> consume(event, relay) - is ChatMessageEvent -> consume(event, relay) - is ChatMessageRelayListEvent -> consume(event, relay) - is ClassifiedsEvent -> consume(event, relay) - is CommentEvent -> consume(event, relay) - is CommunityDefinitionEvent -> consume(event, relay) - is CommunityListEvent -> consume(event, relay) - is CommunityPostApprovalEvent -> { - event.containedPost()?.let { verifyAndConsume(it, relay) } - consume(event) - } - is ContactListEvent -> consume(event) - is DeletionEvent -> consume(event) - is DraftEvent -> consume(event, relay) - is EmojiPackEvent -> consume(event, relay) - is EmojiPackSelectionEvent -> consume(event, relay) - is GenericRepostEvent -> { - event.containedPost()?.let { verifyAndConsume(it, relay) } - consume(event) - } - is FhirResourceEvent -> consume(event, relay) - is FileHeaderEvent -> consume(event, relay) - is ProfileGalleryEntryEvent -> consume(event, relay) - is FileServersEvent -> consume(event, relay) - is FileStorageEvent -> consume(event, relay) - is FileStorageHeaderEvent -> consume(event, relay) - is GiftWrapEvent -> consume(event, relay) - is GitIssueEvent -> consume(event, relay) - is GitReplyEvent -> consume(event, relay) - is GitPatchEvent -> consume(event, relay) - is GitRepositoryEvent -> consume(event, relay) - is HighlightEvent -> consume(event, relay) - is InteractiveStoryPrologueEvent -> consume(event, relay) - is InteractiveStorySceneEvent -> consume(event, relay) - is InteractiveStoryReadingStateEvent -> consume(event, relay) - is LiveActivitiesEvent -> consume(event, relay) - is LiveActivitiesChatMessageEvent -> consume(event, relay) - is LnZapEvent -> { - event.zapRequest?.let { - // must have a valid request - verifyAndConsume(it, relay) - consume(event, relay) - } - } - is LnZapRequestEvent -> consume(event) - is NIP90StatusEvent -> consume(event, relay) - is NIP90ContentDiscoveryResponseEvent -> consume(event, relay) - is NIP90ContentDiscoveryRequestEvent -> consume(event, relay) - is NIP90UserDiscoveryResponseEvent -> consume(event, relay) - is NIP90UserDiscoveryRequestEvent -> consume(event, relay) - is LnZapPaymentRequestEvent -> consume(event) - is LnZapPaymentResponseEvent -> consume(event) - is LongTextNoteEvent -> consume(event, relay) - is MetadataEvent -> consume(event, relay) - is MuteListEvent -> consume(event, relay) - is NNSEvent -> comsume(event, relay) - is OtsEvent -> consume(event, relay) - is PictureEvent -> consume(event, relay) - is PrivateDmEvent -> consume(event, relay) - is PrivateOutboxRelayListEvent -> consume(event, relay) - is PinListEvent -> consume(event, relay) - is PeopleListEvent -> consume(event, relay) - is PollNoteEvent -> consume(event, relay) - is ReactionEvent -> consume(event) - is RelationshipStatusEvent -> consume(event, relay) - is RelaySetEvent -> consume(event, relay) - is ReportEvent -> consume(event, relay) - is RepostEvent -> { - event.containedPost()?.let { verifyAndConsume(it, relay) } - consume(event) - } - is SealedRumorEvent -> consume(event, relay) - is SearchRelayListEvent -> consume(event, relay) - is StatusEvent -> consume(event, relay) - is TextNoteEvent -> consume(event, relay) - is TextNoteModificationEvent -> consume(event, relay) - is TorrentEvent -> consume(event, relay) - is TorrentCommentEvent -> consume(event, relay) - is VideoHorizontalEvent -> consume(event, relay) - is VideoVerticalEvent -> consume(event, relay) - is WikiNoteEvent -> consume(event, relay) + is AdvertisedRelayListEvent -> consume(event, relay, wasVerified) + is AppDefinitionEvent -> consume(event, relay, wasVerified) + is AppRecommendationEvent -> consume(event, relay, wasVerified) + is AppSpecificDataEvent -> consume(event, relay, wasVerified) + is AudioHeaderEvent -> consume(event, relay, wasVerified) + is AudioTrackEvent -> consume(event, relay, wasVerified) + is BadgeAwardEvent -> consume(event, relay, wasVerified) + is BadgeDefinitionEvent -> consume(event, relay, wasVerified) + is BadgeProfilesEvent -> consume(event, relay, wasVerified) + is BlockedRelayListEvent -> consume(event, relay, wasVerified) + is BlossomServersEvent -> consume(event, relay, wasVerified) + is BroadcastRelayListEvent -> consume(event, relay, wasVerified) + is BookmarkListEvent -> consume(event, relay, wasVerified) + is CalendarEvent -> consume(event, relay, wasVerified) + is CalendarDateSlotEvent -> consume(event, relay, wasVerified) + is CalendarTimeSlotEvent -> consume(event, relay, wasVerified) + is CalendarRSVPEvent -> consume(event, relay, wasVerified) + is ChannelCreateEvent -> consume(event, relay, wasVerified) + is ChannelListEvent -> consume(event, relay, wasVerified) + is ChannelHideMessageEvent -> consume(event, relay, wasVerified) + is ChannelMessageEvent -> consume(event, relay, wasVerified) + is ChannelMetadataEvent -> consume(event, relay, wasVerified) + is ChannelMuteUserEvent -> consume(event, relay, wasVerified) + is ChatMessageEncryptedFileHeaderEvent -> consume(event, relay, wasVerified) + is ChatMessageEvent -> consume(event, relay, wasVerified) + is ChatMessageRelayListEvent -> consume(event, relay, wasVerified) + is ClassifiedsEvent -> consume(event, relay, wasVerified) + is CommentEvent -> consume(event, relay, wasVerified) + is CommunityDefinitionEvent -> consume(event, relay, wasVerified) + is CommunityListEvent -> consume(event, relay, wasVerified) + is CommunityPostApprovalEvent -> consume(event, relay, wasVerified) + is ContactListEvent -> consume(event, relay, wasVerified) + is DeletionEvent -> consume(event, relay, wasVerified) + is DraftEvent -> consume(event, relay, wasVerified) + is EmojiPackEvent -> consume(event, relay, wasVerified) + is EmojiPackSelectionEvent -> consume(event, relay, wasVerified) + is EphemeralChatEvent -> consume(event, relay, wasVerified) + is EphemeralChatListEvent -> consume(event, relay, wasVerified) + is GenericRepostEvent -> consume(event, relay, wasVerified) + is FhirResourceEvent -> consume(event, relay, wasVerified) + is FileHeaderEvent -> consume(event, relay, wasVerified) + is ProfileGalleryEntryEvent -> consume(event, relay, wasVerified) + is FileServersEvent -> consume(event, relay, wasVerified) + is FileStorageEvent -> consume(event, relay, wasVerified) + is FileStorageHeaderEvent -> consume(event, relay, wasVerified) + is FollowListEvent -> consume(event, relay, wasVerified) + is GeohashListEvent -> consume(event, relay, wasVerified) + is GoalEvent -> consume(event, relay, wasVerified) + is GiftWrapEvent -> consume(event, relay, wasVerified) + is GitIssueEvent -> consume(event, relay, wasVerified) + is GitReplyEvent -> consume(event, relay, wasVerified) + is GitPatchEvent -> consume(event, relay, wasVerified) + is GitRepositoryEvent -> consume(event, relay, wasVerified) + is HashtagListEvent -> consume(event, relay, wasVerified) + is HighlightEvent -> consume(event, relay, wasVerified) + is IndexerRelayListEvent -> consume(event, relay, wasVerified) + is InteractiveStoryPrologueEvent -> consume(event, relay, wasVerified) + is InteractiveStorySceneEvent -> consume(event, relay, wasVerified) + is InteractiveStoryReadingStateEvent -> consume(event, relay, wasVerified) + is LiveActivitiesEvent -> consume(event, relay, wasVerified) + is LiveActivitiesChatMessageEvent -> consume(event, relay, wasVerified) + is LnZapEvent -> consume(event, relay, wasVerified) + is LnZapRequestEvent -> consume(event, relay, wasVerified) + is NIP90StatusEvent -> consume(event, relay, wasVerified) + is NIP90ContentDiscoveryResponseEvent -> consume(event, relay, wasVerified) + is NIP90ContentDiscoveryRequestEvent -> consume(event, relay, wasVerified) + is NIP90UserDiscoveryResponseEvent -> consume(event, relay, wasVerified) + is NIP90UserDiscoveryRequestEvent -> consume(event, relay, wasVerified) + is LnZapPaymentRequestEvent -> consume(event, relay, wasVerified) + is LnZapPaymentResponseEvent -> consume(event, relay, wasVerified) + is LongTextNoteEvent -> consume(event, relay, wasVerified) + is MetadataEvent -> consume(event, relay, wasVerified) + is MuteListEvent -> consume(event, relay, wasVerified) + is NNSEvent -> consume(event, relay, wasVerified) + is OtsEvent -> consume(event, relay, wasVerified) + is PictureEvent -> consume(event, relay, wasVerified) + is PrivateDmEvent -> consume(event, relay, wasVerified) + is PrivateOutboxRelayListEvent -> consume(event, relay, wasVerified) + is ProxyRelayListEvent -> consume(event, relay, wasVerified) + is PinListEvent -> consume(event, relay, wasVerified) + is PublicMessageEvent -> consume(event, relay, wasVerified) + is PeopleListEvent -> consume(event, relay, wasVerified) + is PollNoteEvent -> consume(event, relay, wasVerified) + is ReactionEvent -> consume(event, relay, wasVerified) + is RelationshipStatusEvent -> consume(event, relay, wasVerified) + is RelaySetEvent -> consume(event, relay, wasVerified) + is ReportEvent -> consume(event, relay, wasVerified) + is RepostEvent -> consume(event, relay, wasVerified) + is SealedRumorEvent -> consume(event, relay, wasVerified) + is SearchRelayListEvent -> consume(event, relay, wasVerified) + is StatusEvent -> consume(event, relay, wasVerified) + is TextNoteEvent -> consume(event, relay, wasVerified) + is TextNoteModificationEvent -> consume(event, relay, wasVerified) + is TorrentEvent -> consume(event, relay, wasVerified) + is TorrentCommentEvent -> consume(event, relay, wasVerified) + is TrustedRelayListEvent -> consume(event, relay, wasVerified) + is VideoHorizontalEvent -> consume(event, relay, wasVerified) + is VideoVerticalEvent -> consume(event, relay, wasVerified) + is VoiceEvent -> consume(event, relay, wasVerified) + is VoiceReplyEvent -> consume(event, relay, wasVerified) + is WikiNoteEvent -> consume(event, relay, wasVerified) else -> { - Log.w("Event Not Supported", event.toJson()) + Log.w("Event Not Supported", "From ${relay?.url}: ${event.toJson()}") + false } } } catch (e: Exception) { if (e is CancellationException) throw e - e.printStackTrace() + Log.w("LocalCache", "Cannot consume ${event.kind}", e) + false } - } fun hasConsumed(notificationEvent: Event): Boolean = if (notificationEvent is AddressableEvent) { - val note = addressables.get(notificationEvent.addressTag()) + val note = addressables.get(notificationEvent.address()) val noteEvent = note?.event noteEvent != null && notificationEvent.createdAt <= noteEvent.createdAt } else { val note = notes.get(notificationEvent.id) note?.event != null } + + fun copyRelaysFromTo( + from: Note, + to: Event, + ) { + val toNote = getOrCreateNote(to) + from.relays.forEach { + toNote.addRelay(it) + } + } + + fun copyRelaysFromTo( + from: Note, + to: HexKey, + ) { + val toNote = getOrCreateNote(to) + from.relays.forEach { + toNote.addRelay(it) + } + } } @Stable class LocalCacheFlow { - private val _newEventBundles = MutableSharedFlow>(0, 10, BufferOverflow.DROP_OLDEST) + private val _newEventBundles = MutableSharedFlow>(0, 100, BufferOverflow.DROP_OLDEST) val newEventBundles = _newEventBundles.asSharedFlow() // read-only public view - // Refreshes observers in batches. - private val bundler = BundledInsert(1000, Dispatchers.IO) + private val _deletedEventBundles = MutableSharedFlow>(0, 100, BufferOverflow.DROP_OLDEST) + val deletedEventBundles = _deletedEventBundles.asSharedFlow() // read-only public view - fun invalidateData(newNote: Note) { + // Refreshes observers in batches. + private val bundler = BundledInsert(1000, Dispatchers.Default) + + // Refreshes observers in batches. + private val bundler2 = BundledInsert(1000, Dispatchers.Default) + + fun newNote(newNote: Note) { bundler.invalidateList(newNote) { bundledNewNotes -> _newEventBundles.emit(bundledNewNotes) } } + + fun removedNote(newNote: Note) { + bundler2.invalidateList(newNote) { bundledNewNotes -> + _deletedEventBundles.emit(bundledNewNotes) + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MediaAspectRatioCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MediaAspectRatioCache.kt index c4a4648112..b4b199f9dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MediaAspectRatioCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MediaAspectRatioCache.kt @@ -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(1000) - override fun get(url: String) = mediaAspectRatioCacheByUrl.get(url) + override fun get(url: String): Float? = mediaAspectRatioCacheByUrl.get(url) override fun add( url: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MiniFhir.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MiniFhir.kt index 03da992ad9..f5150e4bd7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MiniFhir.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MiniFhir.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index 56da5a61c0..03158d3eec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -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? = null + var inGatherers: List? = null + + fun inGatherers() = inGatherers ?: listOf().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() private set @@ -153,13 +177,9 @@ open class Note( var zapPayments = mapOf() private set - var relays = listOf() + var relays = listOf() private set - var lastReactionsDownloadTime: Map = 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 { + val authorRelay = author?.relayHints()?.ifEmpty { null } + + return authorRelay ?: relays + } + + fun relayUrlsForReactions(): List { + 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() zapPayments = mapOf() zapsAmount = BigDecimal.ZERO - relays = listOf() - lastReactionsDownloadTime = emptyMap() + relays = listOf() - 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>() + 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, zapPayments: List>, - 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, - 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, - 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(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 = reactions[content]?.filter { it.author == loggedIn } ?: emptyList() - fun reactedBy(loggedIn: User): List = reactions.filter { it.value.any { it.author == loggedIn } }.mapNotNull { it.key } + fun allReactionsByAuthor(loggedIn: User): List = 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 = 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 toEventHint() = (event as? T)?.let { EventHintBundle(it, relayHintUrl(), author?.bestRelayHint()) } + inline fun toEventHint(): EventHintBundle? { + 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(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 map(transform: (NoteState) -> Y): NoteLoadingLiveData { - 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( - val note: Note, - initialValue: Y?, -) : MediatorLiveData(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, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ParticipantListBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ParticipantListBuilder.kt index 39c9683de3..8e1badf188 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ParticipantListBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ParticipantListBuilder.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Settings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Settings.kt index cc722dc75f..356f79707a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Settings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Settings.kt @@ -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 + } + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt index 0a7b9e5b03..b013507614 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt @@ -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, ) + 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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ThreadLevelCalculator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ThreadLevelCalculator.kt index 4dba1c2730..8b066ce09f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ThreadLevelCalculator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ThreadLevelCalculator.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt index 9334384981..b2e43b2702 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt index eeef874cea..92f6b37943 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -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 = setOf() @@ -73,37 +63,38 @@ class User( var reports = mapOf>() private set - var latestEOSEs: Map = emptyMap() - var zaps = mapOf() private set - var relaysBeingUsed = mapOf() - private set - - var privateChatrooms = mapOf() + var relaysBeingUsed = mapOf() 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): 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(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 map(transform: (UserState) -> Y): UserLoadingLiveData { - 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( - val user: User, - initialValue: Y?, -) : MediatorLiveData(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, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/edits/PrivateStorageRelayListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/edits/PrivateStorageRelayListDecryptionCache.kt new file mode 100644 index 0000000000..ca8e735495 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/edits/PrivateStorageRelayListDecryptionCache.kt @@ -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(signer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/edits/PrivateStorageRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/edits/PrivateStorageRelayListState.kt new file mode 100644 index 0000000000..e4f28c081d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/edits/PrivateStorageRelayListState.kt @@ -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 = getPrivateOutboxRelayListNote().flow().metadata.stateFlow + + fun getPrivateOutboxRelayList(): PrivateOutboxRelayListEvent? = getPrivateOutboxRelayListNote().event as? PrivateOutboxRelayListEvent + + suspend fun normalizePrivateOutboxRelayListWithBackup(note: Note): Set { + 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): 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) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/emphChat/EphemeralChatChannel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/emphChat/EphemeralChatChannel.kt new file mode 100644 index 0000000000..12a28673d2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/emphChat/EphemeralChatChannel.kt @@ -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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/emphChat/EphemeralChatListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/emphChat/EphemeralChatListDecryptionCache.kt new file mode 100644 index 0000000000..60ff2f80fc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/emphChat/EphemeralChatListDecryptionCache.kt @@ -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(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() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/emphChat/EphemeralChatListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/emphChat/EphemeralChatListState.kt new file mode 100644 index 0000000000..0a9378ebb8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/emphChat/EphemeralChatListState.kt @@ -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 = getEphemeralChatListNote().flow().metadata.stateFlow + + fun getEphemeralChatList(): EphemeralChatListEvent? = getEphemeralChatListNote().event as? EphemeralChatListEvent + + suspend fun ephemeralChatListWithBackup(note: Note): Set { + val event = note.event as? EphemeralChatListEvent ?: settings.backupEphemeralChatList + return event?.let { decryptionCache.roomSet(it) } ?: emptySet() + } + + @OptIn(ExperimentalCoroutinesApi::class) + val liveEphemeralChatList: StateFlow> = + 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) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/localRelays/LocalRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/localRelays/LocalRelayListState.kt new file mode 100644 index 0000000000..dcdaa2b36a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/localRelays/LocalRelayListState.kt @@ -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): Set = 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, + onDone: () -> Unit, + ) { + settings.updateLocalRelayServers(relays.map { it.url }.toSet()) + onDone() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/AccountOutboxRelayState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/AccountOutboxRelayState.kt new file mode 100644 index 0000000000..58de33ab47 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/AccountOutboxRelayState.kt @@ -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, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/NotificationInboxRelayState.kt similarity index 59% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayManager.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/NotificationInboxRelayState.kt index 6bcc65dcab..1d05dee9bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/NotificationInboxRelayState.kt @@ -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 = - 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, + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt new file mode 100644 index 0000000000..0061c0385f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt @@ -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 = 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) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListOutboxOrProxyRelays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListOutboxOrProxyRelays.kt new file mode 100644 index 0000000000..cf90954fbf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListOutboxOrProxyRelays.kt @@ -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 = getNIP65RelayListNote(pubkey).flow().metadata.stateFlow + + fun getNIP65RelayList(pubkey: HexKey): AdvertisedRelayListEvent? = getNIP65RelayListNote(pubkey).event as? AdvertisedRelayListEvent + + fun allRelayListFlows(followList: Set): List> = followList.map { getNIP65RelayListFlow(it) } + + fun combineAllFlows(flows: List>): Flow> = + combine(flows) { relayListNotes: Array -> + relayListNotes + .mapNotNull { + (it.note.event as? AdvertisedRelayListEvent)?.writeRelaysNorm() + }.flatten() + .toSet() + } + + @OptIn(ExperimentalCoroutinesApi::class) + val outboxRelayFlow: StateFlow> = + 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> = + 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> = + 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> = + flow + .map { relayList -> + relayList.map { it.url }.toSet() + }.onStart { + emit(flow.value.map { it.url }.toSet()) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListState.kt new file mode 100644 index 0000000000..409f31e154 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListState.kt @@ -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 = getFollowListUser().flow().follows.stateFlow + + fun getFollowListEvent(): ContactListEvent? = getFollowListUser().latestContactList + + @OptIn(ExperimentalCoroutinesApi::class) + private val innerFlow: Flow = + 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 = emptySet(), + val authorsPlusMe: Set, + val hashtags: Set = emptySet(), + val geotags: Set = emptySet(), + val communities: Set = emptySet(), + ) { + val geotagScopes: Set = geotags.mapTo(mutableSetOf()) { GeohashId.toScope(it) } + val hashtagScopes: Set = hashtags.mapTo(mutableSetOf()) { 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) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowsPerOutboxRelay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowsPerOutboxRelay.kt new file mode 100644 index 0000000000..4f6fe71288 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowsPerOutboxRelay.kt @@ -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 = getNIP65RelayListNote(pubkey).flow().metadata.stateFlow + + fun getNIP65RelayList(pubkey: HexKey): AdvertisedRelayListEvent? = getNIP65RelayListNote(pubkey).event as? AdvertisedRelayListEvent + + fun allRelayListFlows(followList: Set): List> = followList.map { getNIP65RelayListFlow(it) } + + fun combineAllRelayListFlows(flows: List>): Flow>> = + combine(flows) { relayListNotes: Array -> + 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>> = + 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>> = + 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>> = + 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(), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/OtsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/OtsState.kt new file mode 100644 index 0000000000..44f316caad --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/OtsState.kt @@ -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 { + 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() + 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(), + ), + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip17Dms/DmInboxRelayState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip17Dms/DmInboxRelayState.kt new file mode 100644 index 0000000000..d1e9def879 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip17Dms/DmInboxRelayState.kt @@ -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, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip17Dms/DmRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip17Dms/DmRelayListState.kt new file mode 100644 index 0000000000..08e3b4111a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip17Dms/DmRelayListState.kt @@ -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 = getDMRelayListNote().flow().metadata.stateFlow + + fun getDMRelayList(): ChatMessageRelayListEvent? = getDMRelayListNote().event as? ChatMessageRelayListEvent + + fun normalizeDMRelayListWithBackup(note: Note): Set { + 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): 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) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip18Reposts/RepostAction.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip18Reposts/RepostAction.kt new file mode 100644 index 0000000000..4d5d1276d2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip18Reposts/RepostAction.kt @@ -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) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip25Reactions/ReactionAction.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip25Reactions/ReactionAction.kt new file mode 100644 index 0000000000..bf580d23c3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip25Reactions/ReactionAction.kt @@ -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()?.let { + onPrivate( + NIP17Factory().createReactionWithinGroup( + emojiUrl = emojiUrl, + originalNote = it, + to = users, + signer = signer, + ), + ) + } + + return + } + } + + note.toEventHint()?.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()?.let { + onPublic(signer.sign(ReactionEvent.build(reaction, it))) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip28PublicChats/PublicChatChannel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip28PublicChats/PublicChatChannel.kt new file mode 100644 index 0000000000..e7ea766ebe --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip28PublicChats/PublicChatChannel.kt @@ -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 +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip28PublicChats/PublicChatListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip28PublicChats/PublicChatListDecryptionCache.kt new file mode 100644 index 0000000000..a0a54ff93d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip28PublicChats/PublicChatListDecryptionCache.kt @@ -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(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() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip28PublicChats/PublicChatListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip28PublicChats/PublicChatListState.kt new file mode 100644 index 0000000000..8ee8452d52 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip28PublicChats/PublicChatListState.kt @@ -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 = getChannelListNote().flow().metadata.stateFlow + + fun getChannelList(): ChannelListEvent? = getChannelListNote().event as? ChannelListEvent + + suspend fun publicChatListWithBackup(note: Note): Set { + val event = note.event as? ChannelListEvent ?: settings.backupChannelList + return event?.let { decryptionCache.channelSet(it) } ?: emptySet() + } + + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow> = + 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> = + 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): 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) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/EmojiPackState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/EmojiPackState.kt new file mode 100644 index 0000000000..b40392a63d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/EmojiPackState.kt @@ -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 = getEmojiPackSelectionNote().flow().metadata.stateFlow + + fun getEmojiPackSelectionNote(): AddressableNote = cache.getOrCreateAddressableNote(getEmojiPackSelectionAddress()) + + fun convertEmojiSelectionPack(selection: EmojiPackSelectionEvent?): List>? = + selection?.taggedAddresses()?.map { + cache + .getOrCreateAddressableNote(it) + .flow() + .metadata.stateFlow + } + + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow>?> = + 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 = + pack.taggedEmojis().map { + EmojiMedia(it.code, MediaUrlImage(it.url)) + } + + fun mergePack(list: Array): List = + 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() ?: 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) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip38UserStatuses/UserStatusAction.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip38UserStatuses/UserStatusAction.kt new file mode 100644 index 0000000000..834e2437f2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip38UserStatuses/UserStatusAction.kt @@ -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 { + 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) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt new file mode 100644 index 0000000000..9444bedbab --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt @@ -0,0 +1,181 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.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 { + 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) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt new file mode 100644 index 0000000000..57069ef61b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt @@ -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 = emptyList(), + val private: List = emptyList(), + ) + + fun getBookmarkListAddress() = BookmarkListEvent.createBookmarkAddress(signer.pubKey) + + fun getBookmarkListNote() = cache.getOrCreateAddressableNote(getBookmarkListAddress()) + + fun getBookmarkListFlow(): StateFlow = getBookmarkListNote().flow().metadata.stateFlow + + fun getBookmarkList(): BookmarkListEvent? = getBookmarkListNote().event as? BookmarkListEvent + + suspend fun publicBookmarks(note: Note): List { + val noteEvent = note.event as? BookmarkListEvent + return noteEvent?.publicBookmarks() ?: emptyList() + } + + suspend fun privateBookmarks(note: Note): List { + val noteEvent = note.event as? BookmarkListEvent + return noteEvent?.privateBookmarks(signer) ?: emptyList() + } + + @OptIn(FlowPreview::class) + val publicBookmarks: StateFlow> = + 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> = + 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, + publicBookmarks: List, + ): 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 = + 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 + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt new file mode 100644 index 0000000000..ce359259b7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt @@ -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>, + val blockList: StateFlow>, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + var transientHiddenUsers: MutableStateFlow> = MutableStateFlow(setOf()) + + @Immutable + class LiveHiddenUsers( + val hiddenUsers: Set, + val spammers: Set, + val hiddenWords: Set, + 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, + muteList: List, + transientHiddenUsers: Set, + 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 = + 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 +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockPeopleList/BlockPeopleListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockPeopleList/BlockPeopleListState.kt new file mode 100644 index 0000000000..2bf5a11cb2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockPeopleList/BlockPeopleListState.kt @@ -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 = getBlockListNote().flow().metadata.stateFlow + + fun getBlockList(): PeopleListEvent? = getBlockListNote().event as? PeopleListEvent + + suspend fun blockListWithBackup(note: Note): List { + 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 + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockPeopleList/PeopleListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockPeopleList/PeopleListDecryptionCache.kt new file mode 100644 index 0000000000..23d5ed4b39 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockPeopleList/PeopleListDecryptionCache.kt @@ -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(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() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockedRelays/BlockedRelayListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockedRelays/BlockedRelayListDecryptionCache.kt new file mode 100644 index 0000000000..a3f3df679f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockedRelays/BlockedRelayListDecryptionCache.kt @@ -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(signer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockedRelays/BlockedRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockedRelays/BlockedRelayListState.kt new file mode 100644 index 0000000000..394bfd1bac --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockedRelays/BlockedRelayListState.kt @@ -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 = getBlockedRelayListNote().flow().metadata.stateFlow + + fun getBlockedRelayList(): BlockedRelayListEvent? = getBlockedRelayListNote().event as? BlockedRelayListEvent + + suspend fun normalizeBlockedRelayListWithBackup(note: Note): Set { + 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): 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) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/broadcastRelays/BroadcastRelayListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/broadcastRelays/BroadcastRelayListDecryptionCache.kt new file mode 100644 index 0000000000..73ba129060 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/broadcastRelays/BroadcastRelayListDecryptionCache.kt @@ -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(signer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/broadcastRelays/BroadcastRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/broadcastRelays/BroadcastRelayListState.kt new file mode 100644 index 0000000000..8cada24fe4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/broadcastRelays/BroadcastRelayListState.kt @@ -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 = getBroadcastRelayListNote().flow().metadata.stateFlow + + fun getBroadcastRelayList(): BroadcastRelayListEvent? = getBroadcastRelayListNote().event as? BroadcastRelayListEvent + + suspend fun normalizeBroadcastRelayListWithBackup(note: Note): Set { + 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): 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, + ) + } + } +} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/TypedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/geohashLists/GeohashListCard.kt similarity index 82% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/TypedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/geohashLists/GeohashListCard.kt index a07e95feb5..25f818e726 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/TypedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/geohashLists/GeohashListCard.kt @@ -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, - val filter: IPerRelayFilter, +data class GeohashListCard( + val relays: List, ) + +val EmptyGeohashListCard = GeohashListCard(emptyList()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/geohashLists/GeohashListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/geohashLists/GeohashListDecryptionCache.kt new file mode 100644 index 0000000000..2576cbc123 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/geohashLists/GeohashListDecryptionCache.kt @@ -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(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 = + 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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/geohashLists/GeohashListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/geohashLists/GeohashListState.kt new file mode 100644 index 0000000000..a11c07130b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/geohashLists/GeohashListState.kt @@ -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 = getGeohashListNote().flow().metadata.stateFlow + + fun getGeohashList(): GeohashListEvent? = getGeohashListNote().event as? GeohashListEvent + + suspend fun geohashListWithBackup(note: Note): Set { + val event = note.event as? GeohashListEvent ?: settings.backupGeohashList + return event?.let { decryptionCache.geohashes(it) } ?: emptySet() + } + + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow> = + getGeohashListFlow() + .transformLatest { noteState -> + emit(geohashListWithBackup(noteState.note)) + }.onStart { + emit(geohashListWithBackup(getGeohashListNote())) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + suspend fun follow(geohashes: List): 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) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/hashtagLists/HashtagListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/hashtagLists/HashtagListDecryptionCache.kt new file mode 100644 index 0000000000..6e73b068ae --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/hashtagLists/HashtagListDecryptionCache.kt @@ -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(signer) + + fun cachedHashtags(event: HashtagListEvent) = cachedPrivateLists.mergeTagListPrecached(event).hashtagSet() + + suspend fun hashtags(event: HashtagListEvent) = cachedPrivateLists.mergeTagList(event).hashtagSet() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/hashtagLists/HashtagListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/hashtagLists/HashtagListState.kt new file mode 100644 index 0000000000..6a73325c59 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/hashtagLists/HashtagListState.kt @@ -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 = getHashtagListNote().flow().metadata.stateFlow + + fun getHashtagList(): HashtagListEvent? = getHashtagListNote().event as? HashtagListEvent + + suspend fun hashtagListWithBackup(note: Note): Set { + val event = note.event as? HashtagListEvent ?: settings.backupHashtagList + return event?.let { decryptionCache.hashtags(it) } ?: emptySet() + } + + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow> = + 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): 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) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListDecryptionCache.kt new file mode 100644 index 0000000000..13d1ce9d4a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListDecryptionCache.kt @@ -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(signer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt new file mode 100644 index 0000000000..5fa08599a1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt @@ -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 = getIndexerRelayListNote().flow().metadata.stateFlow + + fun getIndexerRelayList(): IndexerRelayListEvent? = getIndexerRelayListNote().event as? IndexerRelayListEvent + + suspend fun normalizeIndexerRelayListWithBackup(note: Note): Set { + 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): 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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListDecryptionCache.kt new file mode 100644 index 0000000000..90314fc4a0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListDecryptionCache.kt @@ -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(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() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListState.kt new file mode 100644 index 0000000000..e284cc024a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListState.kt @@ -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 = getMuteListNote().flow().metadata.stateFlow + + fun getMuteList(): MuteListEvent? = getMuteListNote().event as? MuteListEvent + + suspend fun muteListWithBackup(note: Note): List { + 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) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/proxyRelays/ProxyRelayListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/proxyRelays/ProxyRelayListDecryptionCache.kt new file mode 100644 index 0000000000..2f4fff3cdb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/proxyRelays/ProxyRelayListDecryptionCache.kt @@ -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(signer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/proxyRelays/ProxyRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/proxyRelays/ProxyRelayListState.kt new file mode 100644 index 0000000000..2946990a98 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/proxyRelays/ProxyRelayListState.kt @@ -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 = getProxyRelayListNote().flow().metadata.stateFlow + + fun getProxyRelayList(): ProxyRelayListEvent? = getProxyRelayListNote().event as? ProxyRelayListEvent + + suspend fun normalizeProxyRelayListWithBackup(note: Note): Set { + 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): 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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/relayLists/GenericRelayListCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/relayLists/GenericRelayListCache.kt new file mode 100644 index 0000000000..c8735fbda9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/relayLists/GenericRelayListCache.kt @@ -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( + val signer: NostrSigner, +) { + val cachedPrivateLists = PrivateTagArrayEventCache(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 = + 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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/relayLists/RelayListCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/relayLists/RelayListCard.kt new file mode 100644 index 0000000000..bf12514c4c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/relayLists/RelayListCard.kt @@ -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, +) + +val EmptyRelayListCard = RelayListCard(emptyList()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListDecryptionCache.kt new file mode 100644 index 0000000000..bf88a37526 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListDecryptionCache.kt @@ -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(signer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt new file mode 100644 index 0000000000..3c5f2a25f1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt @@ -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 = getSearchRelayListNote().flow().metadata.stateFlow + + fun getSearchRelayList(): SearchRelayListEvent? = getSearchRelayListNote().event as? SearchRelayListEvent + + suspend fun normalizeSearchRelayListWithBackup(note: Note): Set { + 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): 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) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListDecryptionCache.kt new file mode 100644 index 0000000000..276b47abb9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListDecryptionCache.kt @@ -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(signer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.kt new file mode 100644 index 0000000000..d5bd692354 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.kt @@ -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.nip51Lists.trustedRelays + +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.nip51Lists.relayLists.TrustedRelayListEvent +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 TrustedRelayListState( + val signer: NostrSigner, + val cache: LocalCache, + val decryptionCache: TrustedRelayListDecryptionCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun getTrustedRelayListAddress() = TrustedRelayListEvent.Companion.createAddress(signer.pubKey) + + fun getTrustedRelayListNote(): AddressableNote = cache.getOrCreateAddressableNote(getTrustedRelayListAddress()) + + fun getTrustedRelayListFlow(): StateFlow = getTrustedRelayListNote().flow().metadata.stateFlow + + fun getTrustedRelayList(): TrustedRelayListEvent? = getTrustedRelayListNote().event as? TrustedRelayListEvent + + suspend fun normalizeTrustedRelayListWithBackup(note: Note): Set { + val event = note.event as? TrustedRelayListEvent ?: settings.backupTrustedRelayList + return event?.let { decryptionCache.relays(it) } ?: emptySet() + } + + val flow = + getTrustedRelayListFlow() + .map { normalizeTrustedRelayListWithBackup(it.note) } + .onStart { emit(normalizeTrustedRelayListWithBackup(getTrustedRelayListNote())) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Companion.Eagerly, + emptySet(), + ) + + suspend fun saveRelayList(trustedRelays: List): TrustedRelayListEvent { + val relayListForTrusted = getTrustedRelayList() + + return if (relayListForTrusted != null && relayListForTrusted.tags.isNotEmpty()) { + TrustedRelayListEvent.Companion.updateRelayList( + earlierVersion = relayListForTrusted, + relays = trustedRelays, + signer = signer, + ) + } else { + TrustedRelayListEvent.Companion.create( + relays = trustedRelays, + signer = signer, + ) + } + } + + init { + settings.backupTrustedRelayList?.let { + Log.d("AccountRegisterObservers", "Loading saved Trusted relay list ${it.toJson()}") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) } + } + + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "Trusted Relay List Collector Start") + getTrustedRelayListFlow().collect { + Log.d("AccountRegisterObservers", "Updating Trusted Relay List for ${signer.pubKey}") + (it.note.event as? TrustedRelayListEvent)?.let { + settings.updateTrustedRelayList(it) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip53LiveActivities/LiveActivitiesChannel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip53LiveActivities/LiveActivitiesChannel.kt new file mode 100644 index 0000000000..f846613e96 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip53LiveActivities/LiveActivitiesChannel.kt @@ -0,0 +1,73 @@ +/** + * 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.nip53LiveActivities + +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.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent + +@Stable +class LiveActivitiesChannel( + val address: Address, +) : Channel() { + var creator: User? = null + var info: LiveActivitiesEvent? = null + + fun address() = address + + override fun relays() = info?.allRelayUrls()?.toSet() ?: super.relays() + + fun relayHintUrl() = relays().firstOrNull() + + fun relayHintUrls() = relays().take(3) + + fun updateChannelInfo( + creator: User, + channelInfo: LiveActivitiesEvent, + ) { + this.info = channelInfo + this.creator = creator + super.updateChannelInfo() + } + + override fun toBestDisplayName(): String = info?.title() ?: toNAddr().toShortDisplay() + + fun creatorName(): String? = creator?.toBestDisplayName() + + fun summary(): String? = info?.summary() + + fun profilePicture(): String? = info?.image()?.ifBlank { null } + + fun anyNameStartsWith(prefix: String): Boolean = + info?.title()?.contains(prefix, true) == true || + info?.summary()?.contains(prefix, true) == true + + fun toNAddr() = NAddress.create(address.kind, address.pubKeyHex, address.dTag, relayHintUrls()) + + fun toATag() = ATag(address, relayHintUrl()) + + fun toNostrUri() = "nostr:${toNAddr()}" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip56Reports/ReportAction.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip56Reports/ReportAction.kt new file mode 100644 index 0000000000..cb534fa395 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip56Reports/ReportAction.kt @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip56Reports + +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip56Reports.ReportEvent +import com.vitorpamplona.quartz.nip56Reports.ReportType + +class ReportAction { + companion object { + suspend fun report( + user: User, + type: ReportType, + by: User, + signer: NostrSigner, + ): ReportEvent? { + if (user.hasReport(by, type)) { + // has already reported this note + return null + } + + val template = ReportEvent.build(user.pubkeyHex, type) + + return signer.sign(template) + } + + suspend fun report( + note: Note, + type: ReportType, + content: String = "", + by: User, + signer: NostrSigner, + ): ReportEvent? { + if (note.hasReport(by, type)) { + // has already reported this note + return null + } + + return note.event?.let { + signer.sign(ReportEvent.build(it, type)) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/Nip65RelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/Nip65RelayListState.kt new file mode 100644 index 0000000000..9df03f4419 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/Nip65RelayListState.kt @@ -0,0 +1,142 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip65RelayList + +import android.util.Log +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.Constants +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class Nip65RelayListState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun getNIP65RelayListAddress() = AdvertisedRelayListEvent.createAddress(signer.pubKey) + + fun getNIP65RelayListNote(): AddressableNote = cache.getOrCreateAddressableNote(getNIP65RelayListAddress()) + + fun getNIP65RelayListFlow(): StateFlow = getNIP65RelayListNote().flow().metadata.stateFlow + + fun getNIP65RelayList(): AdvertisedRelayListEvent? = getNIP65RelayListNote().event as? AdvertisedRelayListEvent + + fun normalizeNIP65WriteRelayListWithBackup(note: Note): Set { + val event = note.event as? AdvertisedRelayListEvent ?: settings.backupNIP65RelayList + return event?.writeRelaysNorm()?.toSet() ?: Constants.eventFinderRelays + } + + fun normalizeNIP65ReadRelayListWithBackup(note: Note): Set { + val event = note.event as? AdvertisedRelayListEvent ?: settings.backupNIP65RelayList + return event?.readRelaysNorm()?.toSet() ?: Constants.eventFinderRelays + } + + fun normalizeNIP65AllRelayListWithBackup(note: Note): Set { + val event = note.event as? AdvertisedRelayListEvent ?: settings.backupNIP65RelayList + return event?.relays()?.map { it.relayUrl }?.toSet() ?: Constants.eventFinderRelays + } + + val outboxFlow = + getNIP65RelayListFlow() + .map { normalizeNIP65WriteRelayListWithBackup(it.note) } + .onStart { emit(normalizeNIP65ReadRelayListWithBackup(getNIP65RelayListNote())) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + val inboxFlow = + getNIP65RelayListFlow() + .map { normalizeNIP65ReadRelayListWithBackup(it.note) } + .onStart { emit(normalizeNIP65ReadRelayListWithBackup(getNIP65RelayListNote())) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + val allFlow = + getNIP65RelayListFlow() + .map { normalizeNIP65AllRelayListWithBackup(it.note) } + .onStart { emit(normalizeNIP65AllRelayListWithBackup(getNIP65RelayListNote())) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + suspend fun saveRelayList(relays: List): AdvertisedRelayListEvent { + val nip65RelayList = getNIP65RelayList() + + return if (nip65RelayList != null) { + AdvertisedRelayListEvent.replaceRelayListWith( + earlierVersion = nip65RelayList, + newRelays = relays, + signer = signer, + ) + } else { + AdvertisedRelayListEvent.createFromScratch( + relays = relays, + signer = signer, + ) + } + } + + init { + settings.backupNIP65RelayList?.let { + Log.d("AccountRegisterObservers", "Loading saved nip65 relay list ${it.toJson()}") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) } + } + + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "NIP-65 Relay List Collector Start") + getNIP65RelayListFlow().collect { + Log.d("AccountRegisterObservers", "Updating NIP-65 List for ${signer.pubKey}") + (it.note.event as? AdvertisedRelayListEvent)?.let { + settings.updateNIP65RelayList(it) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/OutboxRelaySetState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/OutboxRelaySetState.kt new file mode 100644 index 0000000000..8f37e8cf34 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/OutboxRelaySetState.kt @@ -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.nip65RelayList + +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest + +class OutboxRelaySetState( + usersToLoad: MutableStateFlow>, + val cache: LocalCache, + scope: CoroutineScope, +) { + fun getNIP65RelayListAddress(pubkey: HexKey) = AdvertisedRelayListEvent.Companion.createAddress(pubkey) + + fun getNIP65RelayListNote(pubkey: HexKey): AddressableNote = cache.getOrCreateAddressableNote(getNIP65RelayListAddress(pubkey)) + + fun getNIP65RelayListFlow(pubkey: HexKey): StateFlow = getNIP65RelayListNote(pubkey).flow().metadata.stateFlow + + fun getNIP65RelayList(pubkey: HexKey): AdvertisedRelayListEvent? = getNIP65RelayListNote(pubkey).event as? AdvertisedRelayListEvent + + fun allRelayListFlows(followList: Set): List> = followList.map { getNIP65RelayListFlow(it) } + + fun combineAllFlows(flows: List>): Flow> = + combine(flows) { relayListNotes: Array -> + relayListNotes.mapNotNull { + (it.note.event as? AdvertisedRelayListEvent)?.writeRelaysNorm() + } + }.map { + it.flatten().toSet() + } + + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow> = + usersToLoad + .transformLatest { followList -> + val flows: List> = allRelayListFlows(followList) + val relayListFlows = combineAllFlows(flows) + emitAll(relayListFlows) + }.onStart { + emit( + usersToLoad.value + .mapNotNull { + getNIP65RelayList(it)?.writeRelaysNorm() + }.flatten() + .toSet(), + ) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Companion.Eagerly, + emptySet(), + ) + + @OptIn(ExperimentalCoroutinesApi::class) + val flowSet: StateFlow> = + flow + .map { relayList -> + relayList.map { it.url }.toSet() + }.onStart { + emit( + usersToLoad.value + .mapNotNull { + getNIP65RelayList(it)?.writeRelaysNorm()?.map { it.url }?.toSet() + }.flatten() + .toSet(), + ) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Companion.Eagerly, + emptySet(), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip72Communities/CommunityListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip72Communities/CommunityListDecryptionCache.kt new file mode 100644 index 0000000000..2ea70cb3a3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip72Communities/CommunityListDecryptionCache.kt @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip72Communities + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache +import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent +import com.vitorpamplona.quartz.nip72ModCommunities.follow.communities +import com.vitorpamplona.quartz.nip72ModCommunities.follow.communityIdSet +import com.vitorpamplona.quartz.nip72ModCommunities.follow.communityIds + +class CommunityListDecryptionCache( + val signer: NostrSigner, +) { + val cachedPrivateLists = PrivateTagArrayEventCache(signer) + + fun cachedCommunityIds(event: CommunityListEvent) = cachedPrivateLists.mergeTagListPrecached(event).communityIds() + + fun cachedCommunityIdSet(event: CommunityListEvent) = cachedPrivateLists.mergeTagListPrecached(event).communityIdSet() + + fun cachedCommunities(event: CommunityListEvent) = cachedPrivateLists.mergeTagListPrecached(event).communities() + + suspend fun communityIds(event: CommunityListEvent) = cachedPrivateLists.mergeTagList(event).communityIds() + + suspend fun communityIdSet(event: CommunityListEvent) = cachedPrivateLists.mergeTagList(event).communityIdSet() + + suspend fun communities(event: CommunityListEvent) = cachedPrivateLists.mergeTagList(event).communities() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip72Communities/CommunityListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip72Communities/CommunityListState.kt new file mode 100644 index 0000000000..9406632f6b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip72Communities/CommunityListState.kt @@ -0,0 +1,159 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip72Communities + +import android.util.Log +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent +import com.vitorpamplona.quartz.nip72ModCommunities.follow.tags.CommunityTag +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 CommunityListState( + val signer: NostrSigner, + val cache: LocalCache, + val decryptionCache: CommunityListDecryptionCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun getCommunityListAddress() = CommunityListEvent.createAddress(signer.pubKey) + + fun getCommunityListNote(): AddressableNote = cache.getOrCreateAddressableNote(getCommunityListAddress()) + + fun getCommunityListFlow(): StateFlow = getCommunityListNote().flow().metadata.stateFlow + + fun getCommunityList(): CommunityListEvent? = getCommunityListNote().event as? CommunityListEvent + + suspend fun communityListWithBackup(note: Note): Set { + val event = note.event as? CommunityListEvent ?: settings.backupCommunityList + return event?.let { decryptionCache.communities(it).toSet() } ?: emptySet() + } + + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow> = + getCommunityListFlow() + .transformLatest { noteState -> + emit(communityListWithBackup(noteState.note)) + }.onStart { + emit(communityListWithBackup(getCommunityListNote())) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + @OptIn(ExperimentalCoroutinesApi::class) + val flowSet: StateFlow> = + flow + .map { hint -> + hint.mapTo(mutableSetOf()) { it.address.toValue() } + }.onStart { + emit(flow.value.mapTo(mutableSetOf()) { it.address.toValue() }) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + suspend fun follow(communities: List): CommunityListEvent { + val communityList = getCommunityList() + + val communityTags = + communities.mapNotNull { community -> + if (community.address.kind == CommunityDefinitionEvent.KIND) { + CommunityTag(community.address, community.relayHintUrl()) + } else { + null + } + } + + return if (communityList == null) { + CommunityListEvent.create(communityTags, true, signer) + } else { + CommunityListEvent.add(communityList, communityTags, true, signer) + } + } + + suspend fun follow(community: AddressableNote): CommunityListEvent? { + val communityList = getCommunityList() + if (community.address.kind != CommunityDefinitionEvent.KIND) return communityList + + return if (communityList == null) { + CommunityListEvent.create( + CommunityTag(community.address, community.relayHintUrl()), + true, + signer, + ) + } else { + CommunityListEvent.add(communityList, CommunityTag(community.address, community.relayHintUrl()), true, signer) + } + } + + suspend fun unfollow(community: AddressableNote): CommunityListEvent? { + val communityList = getCommunityList() + if (community.address.kind != CommunityDefinitionEvent.KIND) return communityList + + return if (communityList != null) { + CommunityListEvent.remove(communityList, CommunityTag(community.address, community.relayHintUrl()), signer) + } else { + null + } + } + + init { + settings.backupCommunityList?.let { event -> + Log.d("AccountRegisterObservers", "Loading saved Community list ${event.toJson()}") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { + LocalCache.justConsumeMyOwnEvent(event) + } + } + + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "Community List Collector Start") + getCommunityListFlow().collect { + Log.d("AccountRegisterObservers", "Community List for ${signer.pubKey}") + (it.note.event as? CommunityListEvent)?.let { + settings.updateCommunityListTo(it) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt new file mode 100644 index 0000000000..c6b203ba1e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt @@ -0,0 +1,105 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip78AppSpecific + +import android.util.Log +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AccountSyncedSettingsInternal +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException + +class AppSpecificState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + companion object { + const val APP_SPECIFIC_DATA_D_TAG = "AmethystSettings" + } + + fun getAppSpecificDataAddress() = AppSpecificDataEvent.createAddress(signer.pubKey, APP_SPECIFIC_DATA_D_TAG) + + fun getAppSpecificDataNote() = cache.getOrCreateAddressableNote(getAppSpecificDataAddress()) + + fun getAppSpecificDataFlow(): StateFlow = getAppSpecificDataNote().flow().metadata.stateFlow + + suspend fun saveNewAppSpecificData(): AppSpecificDataEvent { + val toInternal = settings.syncedSettings.toInternal() + return AppSpecificDataEvent.create( + dTag = APP_SPECIFIC_DATA_D_TAG, + description = signer.nip44Encrypt(JsonMapper.mapper.writeValueAsString(toInternal), signer.pubKey), + otherTags = emptyArray(), + signer = signer, + ) + } + + init { + settings.backupAppSpecificData?.let { event -> + Log.d("AccountRegisterObservers", "Loading saved app specific data ${event.toJson()}") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { + LocalCache.justConsumeMyOwnEvent(event) + try { + val decrypted = signer.decrypt(event.content, event.pubKey) + val syncedSettings = JsonMapper.mapper.readValue(decrypted) + settings.syncedSettings.updateFrom(syncedSettings) + } catch (e: Throwable) { + if (e is CancellationException) throw e + Log.w("LocalPreferences", "Error Decoding latestAppSpecificData from Preferences with value", e) + } + } + } + + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "AppSpecificData Collector Start") + getAppSpecificDataFlow().collect { + try { + Log.d("AccountRegisterObservers", "Updating AppSpecificData for ${signer.pubKey}") + (it.note.event as? AppSpecificDataEvent)?.let { + val decrypted = signer.decrypt(it.content, it.pubKey) + try { + val syncedSettings = JsonMapper.mapper.readValue(decrypted) + settings.updateAppSpecificData(it, syncedSettings) + } catch (e: Throwable) { + if (e is CancellationException) throw e + Log.w("LocalPreferences", "Error Decoding latestAppSpecificData from Preferences with value $decrypted", e) + } + } + } catch (e: Throwable) { + if (e is CancellationException) throw e + Log.w("LocalPreferences", "Error Decrypting latestAppSpecificData from Preferences", e) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip96FileStorage/FileStorageServerListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip96FileStorage/FileStorageServerListState.kt new file mode 100644 index 0000000000..05af740395 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip96FileStorage/FileStorageServerListState.kt @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip96FileStorage + +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +class FileStorageServerListState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun getFileServersAddress() = FileServersEvent.createAddress(signer.pubKey) + + fun getFileServersNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(getFileServersAddress()) + + fun getFileServersListFlow(): StateFlow = getFileServersNote().flow().metadata.stateFlow + + fun getFileServersList(): FileServersEvent? = getFileServersNote().event as? FileServersEvent + + fun normalizeServers(note: Note): List { + val event = note.event as? FileServersEvent + return event?.servers() ?: emptyList() + } + + val flow = + getFileServersListFlow() + .map { normalizeServers(it.note) } + .onStart { emit(normalizeServers(getFileServersNote())) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + suspend fun saveFileServersList(servers: List): FileServersEvent { + val serverList = getFileServersList() + + val template = + if (serverList != null && serverList.tags.isNotEmpty()) { + FileServersEvent.replaceServers(serverList, servers) + } else { + FileServersEvent.build(servers) + } + + return signer.sign(template) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt new file mode 100644 index 0000000000..5d8557d35e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt @@ -0,0 +1,98 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nipB7Blossom + +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +class BlossomServerListState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun getBlossomServersAddress() = BlossomServersEvent.createAddress(signer.pubKey) + + fun getBlossomServersNote(): AddressableNote = cache.getOrCreateAddressableNote(getBlossomServersAddress()) + + fun getBlossomServersListFlow(): StateFlow = getBlossomServersNote().flow().metadata.stateFlow + + fun getBlossomServersList(): BlossomServersEvent? = getBlossomServersNote().event as? BlossomServersEvent + + fun normalizeServers(note: Note): List { + val event = note.event as? BlossomServersEvent + return event?.servers() ?: emptyList() + } + + val flow = + getBlossomServersListFlow() + .map { normalizeServers(it.note) } + .onStart { emit(normalizeServers(getBlossomServersNote())) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + suspend fun saveBlossomServersList(servers: List): BlossomServersEvent { + val serverList = getBlossomServersList() + + return if (serverList != null && serverList.tags.isNotEmpty()) { + BlossomServersEvent.updateRelayList( + earlierVersion = serverList, + relays = servers, + signer = signer, + ) + } else { + BlossomServersEvent.createFromScratch( + relays = servers, + signer = signer, + ) + } + } + + suspend fun createBlossomUploadAuth( + hash: HexKey, + size: Long, + alt: String, + ): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer) + + suspend fun createBlossomDeleteAuth( + hash: HexKey, + alt: String, + ): BlossomAuthorizationEvent? = BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/observables/CreatedAtComparator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/observables/CreatedAtComparator.kt index 1cd47498de..c13a4be330 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/observables/CreatedAtComparator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/observables/CreatedAtComparator.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/observables/LatestByKindAndAuthor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/observables/LatestByKindAndAuthor.kt index 9598b435c7..d7d3831988 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/observables/LatestByKindAndAuthor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/observables/LatestByKindAndAuthor.kt @@ -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,6 +24,7 @@ import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -51,10 +52,8 @@ class LatestByKindAndAuthor( if ((kind in 10000..19999) || (kind in 30000..39999)) { LocalCache.addressables .maxOrNullOf( - filter = { idHex: String, note: AddressableNote -> - note.event?.let { - it.kind == kind && it.pubKey == pubkey - } == true + filter = { address: Address, note: AddressableNote -> + address.kind == kind && address.pubKeyHex == pubkey }, comparator = CreatedAtComparatorAddresses, )?.event as? T diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/observables/LatestByKindWithETag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/observables/LatestByKindWithETag.kt index b051cda909..057b710305 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/observables/LatestByKindWithETag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/observables/LatestByKindWithETag.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountPreferenceStores.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountPreferenceStores.kt new file mode 100644 index 0000000000..d847670418 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountPreferenceStores.kt @@ -0,0 +1,82 @@ +/** + * 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.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.quartz.utils.LargeCache +import java.io.File + +class AccountPreferenceStores( + val rootFilesDir: () -> File, +) { + companion object { + val defaultHomeFollowList = stringPreferencesKey("defaultHomeFollowList") + val defaultStoriesFollowList = stringPreferencesKey("defaultStoriesFollowList") + val defaultNotificationFollowList = stringPreferencesKey("defaultNotificationFollowList") + val defaultDiscoveryFollowList = stringPreferencesKey("defaultDiscoveryFollowList") + + val localRelayServers = stringPreferencesKey("localRelayServers") + val defaultFileServer = stringPreferencesKey("defaultFileServer") + + val latestUserMetadata = stringPreferencesKey("latestUserMetadata") + val latestContactList = stringPreferencesKey("latestContactList") + val latestDMRelayList = stringPreferencesKey("latestDMRelayList") + val latestNIP65RelayList = stringPreferencesKey("latestNIP65RelayList") + val latestSearchRelayList = stringPreferencesKey("latestSearchRelayList") + val latestBlockedRelayList = stringPreferencesKey("latestBlockedRelayList") + val latestTrustedRelayList = stringPreferencesKey("latestTrustedRelayList") + val latestMuteList = stringPreferencesKey("latestMuteList") + val latestPrivateHomeRelayList = stringPreferencesKey("latestPrivateHomeRelayList") + val latestAppSpecificData = stringPreferencesKey("latestAppSpecificData") + val latestChannelList = stringPreferencesKey("latestChannelList") + val latestCommunityList = stringPreferencesKey("latestCommunityList") + val latestHashtagList = stringPreferencesKey("latestHashtagList") + val latestGeohashList = stringPreferencesKey("latestGeohashList") + val latestEphemeralChatList = stringPreferencesKey("latestEphemeralChatList") + + val hideDeleteRequestDialog = stringPreferencesKey("hideDeleteRequestDialog") + val hideBlockAlertDialog = stringPreferencesKey("hideBlockAlertDialog") + val hideNip17WarningDialog = stringPreferencesKey("hideNip17WarningDialog") + + val torSettings = stringPreferencesKey("tor_settings") + + val hasDonatedInVersion = stringPreferencesKey("hasDonatedInVersion") + } + + private val storeCache = LargeCache>() + + fun file(npub: String) = File(rootFilesDir(), "datastore/$npub.preferences") + + private fun getDataStore(npub: String): DataStore = + storeCache.getOrCreate(npub) { + PreferenceDataStoreFactory.create( + produceFile = { file(npub) }, + ) + } + + fun removeAccount(npub: String) { + file(npub).delete() + storeCache.remove(npub) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountSecretsEncryptedStores.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountSecretsEncryptedStores.kt new file mode 100644 index 0000000000..07f561976e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountSecretsEncryptedStores.kt @@ -0,0 +1,73 @@ +/** + * 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.preferences + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import com.vitorpamplona.quartz.utils.LargeCache +import java.io.File + +class AccountSecretsEncryptedStores( + val rootFilesDir: () -> File, +) { + companion object Companion { + val encryption = KeyStoreEncryption() + val key = stringPreferencesKey("privKey") + val nwc = stringPreferencesKey("nwc") + } + + private val storeCache = LargeCache() + + fun file(npub: String) = File(rootFilesDir(), "datastore/$npub.secrets") + + private fun getDataStore(npub: String): EncryptedDataStore = + storeCache.getOrCreate(npub) { + EncryptedDataStore( + PreferenceDataStoreFactory.create( + produceFile = { file(npub) }, + ), + encryption, + ) + } + + suspend fun getPrivateKey(npub: String): String? = getDataStore(npub).get(key) + + suspend fun savePrivateKey( + npub: String, + value: HexKey, + ) { + getDataStore(npub).save(key, value) + } + + suspend fun nwc(npub: String): UpdatablePropertyFlow = + getDataStore(npub).getProperty( + key = nwc, + parser = Nip47WalletConnect.Nip47URI::parser, + serializer = Nip47WalletConnect.Nip47URI::serializer, + ) + + fun removeAccount(npub: String) { + file(npub).delete() + storeCache.remove(npub) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DataStoreExt.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DataStoreExt.kt new file mode 100644 index 0000000000..1e5bc5fbb1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DataStoreExt.kt @@ -0,0 +1,70 @@ +/** + * 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.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav.scope +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URI.Companion.parser +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import java.io.IOException + +suspend fun DataStore.getProperty( + key: Preferences.Key, + parser: (String) -> T, + serializer: (T) -> String, +): UpdatablePropertyFlow = + UpdatablePropertyFlow( + flow = + data + .catch { e -> + if (e is IOException) emit(emptyPreferences()) else throw e + }.map { prefs -> + val value = prefs[key] + if (value != null) { + parser(value) + } else { + null + } + }, + update = { newValue -> + if (newValue != null) { + val serialized = serializer(newValue) + if (serialized.isNotBlank()) { + edit { prefs -> + prefs[key] = serialized + } + } else { + edit { prefs -> + prefs.remove(key) + } + } + } else { + edit { prefs -> + prefs.remove(key) + } + } + }, + scope = scope, + ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/EncryptedDataStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/EncryptedDataStore.kt new file mode 100644 index 0000000000..b14d65f288 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/EncryptedDataStore.kt @@ -0,0 +1,106 @@ +/** + * 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.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav.scope +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import java.io.IOException +import java.util.Base64 + +class EncryptedDataStore( + private val store: DataStore, + private val encryption: KeyStoreEncryption = KeyStoreEncryption(), +) { + private fun decode(str: String): ByteArray = Base64.getDecoder().decode(str) + + private fun encode(bytes: ByteArray): String = Base64.getEncoder().encodeToString(bytes) + + private fun encrypt(value: String): String = encode(encryption.encrypt(value.toByteArray())) + + private fun decrypt(value: String): String = encryption.decrypt(decode(value)).contentToString() + + suspend fun remove(key: Preferences.Key) { + store.edit { prefs -> + prefs.remove(key) + } + } + + suspend fun save( + key: Preferences.Key, + value: String, + ) { + store.edit { prefs -> + prefs[key] = encrypt(value) + } + } + + suspend fun get(key: Preferences.Key): String? = + store.data + .catch { e -> + if (e is IOException) emit(emptyPreferences()) else throw e + }.firstOrNull() + ?.get(key) + ?.let { decrypt(it) } + + suspend fun getProperty( + key: Preferences.Key, + parser: (String) -> T, + serializer: (T) -> String, + ): UpdatablePropertyFlow = + UpdatablePropertyFlow( + flow = + store.data + .catch { e -> + if (e is IOException) emit(emptyPreferences()) else throw e + }.map { prefs -> + val value = prefs[key] + if (value != null) { + val decrypted = decrypt(value) + if (decrypted.isNotBlank()) { + parser(decrypted) + } else { + null + } + } else { + null + } + }, + update = { newValue -> + if (newValue != null) { + val serialized = serializer(newValue) + if (serialized.isNotBlank()) { + save(key, serialized) + } else { + remove(key) + } + } else { + remove(key) + } + }, + scope = scope, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/KeyStoreEncryption.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/KeyStoreEncryption.kt new file mode 100644 index 0000000000..eca8de6873 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/KeyStoreEncryption.kt @@ -0,0 +1,102 @@ +/** + * 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.preferences + +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.security.keystore.StrongBoxUnavailableException +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.IvParameterSpec + +class KeyStoreEncryption { + companion object { + private const val ALGORITHM = KeyProperties.KEY_ALGORITHM_AES + private const val BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM + private const val PADDING = KeyProperties.ENCRYPTION_PADDING_PKCS7 + private const val TRANSFORMATION = "$ALGORITHM/$BLOCK_MODE/$PADDING" + private const val PURPOSE = KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + private const val KEY_ALIAS = "AMETHYST_AES_KEY" + } + + private val cipher = Cipher.getInstance(TRANSFORMATION) + private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + + private fun getKey(): SecretKey { + val existingKey = keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry + return existingKey?.secretKey ?: createKey() + } + + private fun createKeyStrongBoxIfAvailable(): SecretKey? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + try { + val keyParams = + KeyGenParameterSpec + .Builder(KEY_ALIAS, PURPOSE) + .setBlockModes(BLOCK_MODE) + .setEncryptionPaddings(PADDING) + .setIsStrongBoxBacked(true) + .build() + + val generator = KeyGenerator.getInstance(ALGORITHM) + generator.init(keyParams) + generator.generateKey() + } catch (_: StrongBoxUnavailableException) { + null + } + } else { + null + } + + private fun createKeyRegular(): SecretKey { + val keyParams = + KeyGenParameterSpec + .Builder(KEY_ALIAS, PURPOSE) + .setBlockModes(BLOCK_MODE) + .setEncryptionPaddings(PADDING) + .build() + + val generator = KeyGenerator.getInstance(ALGORITHM) + generator.init(keyParams) + return generator.generateKey() + } + + private fun createKey(): SecretKey = createKeyStrongBoxIfAvailable() ?: createKeyRegular() + + fun encrypt(bytes: ByteArray): ByteArray { + // Initializes the cipher in encrypt mode and encrypts data + cipher.init(Cipher.ENCRYPT_MODE, getKey()) + val iv = cipher.iv + val encrypted = cipher.doFinal(bytes) + return iv + encrypted + } + + fun decrypt(bytes: ByteArray): ByteArray? { + // Extracts IV and decrypts the data + val iv = bytes.copyOfRange(0, cipher.blockSize) + val data = bytes.copyOfRange(cipher.blockSize, bytes.size) + cipher.init(Cipher.DECRYPT_MODE, getKey(), IvParameterSpec(iv)) + return cipher.doFinal(data) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3BasicRelaySetupInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UpdatablePropertyFlow.kt similarity index 61% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3BasicRelaySetupInfo.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UpdatablePropertyFlow.kt index ecaaf6896c..af96b24f56 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3BasicRelaySetupInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UpdatablePropertyFlow.kt @@ -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,21 +18,26 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kind3 +package com.vitorpamplona.amethyst.model.preferences -import androidx.compose.runtime.Immutable -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache -import com.vitorpamplona.quartz.nip01Core.relay.RelayStat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn -@Immutable -data class Kind3BasicRelaySetupInfo( - val url: String, - val read: Boolean, - val write: Boolean, - val feedTypes: Set, - val relayStat: RelayStat, - val paidRelay: Boolean = false, +class UpdatablePropertyFlow( + flow: Flow, + val update: suspend (T?) -> Unit, + val scope: CoroutineScope, ) { - val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url) + val stateFlow = + flow + .flowOn(Dispatchers.Default) + .stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = null, + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/PrivacyState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/PrivacyState.kt new file mode 100644 index 0000000000..dcbee7e43d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/PrivacyState.kt @@ -0,0 +1,125 @@ +/** + * 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.privacyOptions + +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.ui.tor.TorType +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + +class PrivacyState( + val settings: AccountSettings, +) { + fun shouldUseTorForImageDownload(url: String) = + shouldUseTorFor( + url, + settings.torSettings.torType.value, + settings.torSettings.imagesViaTor.value, + ) + + fun shouldUseTorFor( + url: String, + torType: TorType, + imagesViaTor: Boolean, + ) = when (torType) { + TorType.OFF -> false + TorType.INTERNAL -> shouldUseTor(url, imagesViaTor) + TorType.EXTERNAL -> shouldUseTor(url, imagesViaTor) + } + + private fun shouldUseTor( + normalizedUrl: String, + final: Boolean, + ): Boolean = + if (RelayUrlNormalizer.isLocalHost(normalizedUrl)) { + false + } else if (RelayUrlNormalizer.isOnion(normalizedUrl)) { + true + } else { + final + } + + fun shouldUseTorForVideoDownload() = + when (settings.torSettings.torType.value) { + TorType.OFF -> false + TorType.INTERNAL -> settings.torSettings.videosViaTor.value + TorType.EXTERNAL -> settings.torSettings.videosViaTor.value + } + + fun shouldUseTorForVideoDownload(url: String) = + when (settings.torSettings.torType.value) { + TorType.OFF -> false + TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.videosViaTor.value) + TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.videosViaTor.value) + } + + fun shouldUseTorForPreviewUrl(url: String) = + when (settings.torSettings.torType.value) { + TorType.OFF -> false + TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.urlPreviewsViaTor.value) + TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.urlPreviewsViaTor.value) + } + + fun shouldUseTorForTrustedRelays() = + when (settings.torSettings.torType.value) { + TorType.OFF -> false + TorType.INTERNAL -> settings.torSettings.trustedRelaysViaTor.value + TorType.EXTERNAL -> settings.torSettings.trustedRelaysViaTor.value + } + + private fun checkLocalHostOnionAndThen( + url: String, + final: Boolean, + ): Boolean = checkLocalHostOnionAndThen(url, settings.torSettings.onionRelaysViaTor.value, final) + + private fun checkLocalHostOnionAndThen( + normalizedUrl: String, + isOnionRelaysActive: Boolean, + final: Boolean, + ): Boolean = + if (RelayUrlNormalizer.isLocalHost(normalizedUrl)) { + false + } else if (RelayUrlNormalizer.isOnion(normalizedUrl)) { + isOnionRelaysActive + } else { + final + } + + fun shouldUseTorForMoneyOperations(url: String) = + when (settings.torSettings.torType.value) { + TorType.OFF -> false + TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.moneyOperationsViaTor.value) + TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.moneyOperationsViaTor.value) + } + + fun shouldUseTorForNIP05(url: String) = + when (settings.torSettings.torType.value) { + TorType.OFF -> false + TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.nip05VerificationsViaTor.value) + TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.nip05VerificationsViaTor.value) + } + + fun shouldUseTorForUploads(url: String) = + when (settings.torSettings.torType.value) { + TorType.OFF -> false + TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.nip96UploadsViaTor.value) + TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.nip96UploadsViaTor.value) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Chatroom.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privateChats/Chatroom.kt similarity index 59% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/Chatroom.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/privateChats/Chatroom.kt index 77b71ebe51..969e84bb4f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Chatroom.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privateChats/Chatroom.kt @@ -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,67 +18,83 @@ * 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 +package com.vitorpamplona.amethyst.model.privateChats import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NotesGatherer +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip14Subject.subject import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.MutableStateFlow @Stable -class Chatroom { - var authors: Set = setOf() - var roomMessages: Set = setOf() - var subject: String? = null +class Chatroom : NotesGatherer { + var activeSenders: Set = setOf() + var messages: Set = setOf() + var subject = MutableStateFlow(null) var subjectCreatedAt: Long? = null + var ownerSentMessage: Boolean = false + var lastMessage: Note? = null + + override fun removeNote(note: Note) { + removeMessageSync(note) + } @Synchronized - fun addMessageSync(msg: Note) { - checkNotInMainThread() - - if (msg !in roomMessages) { - roomMessages = roomMessages + msg + fun addMessageSync(msg: Note): Boolean { + if (msg !in messages) { + messages = messages + msg + msg.addGatherer(this) msg.author?.let { author -> - if (author !in authors) { - authors += author + if (author !in activeSenders) { + activeSenders += author } } + val createdAt = msg.createdAt() ?: 0 + if (createdAt > (lastMessage?.createdAt() ?: 0)) { + lastMessage = msg + } + val newSubject = msg.event?.subject() if (newSubject != null && (msg.createdAt() ?: 0) > (subjectCreatedAt ?: 0)) { - subject = newSubject + subject.tryEmit(newSubject) subjectCreatedAt = msg.createdAt() } + return true } + return false } @Synchronized - fun removeMessageSync(msg: Note) { - checkNotInMainThread() + fun removeMessageSync(msg: Note): Boolean { + if (msg in messages) { + messages = messages - msg + msg.removeGatherer(this) - if (msg in roomMessages) { - roomMessages = roomMessages - msg - - roomMessages + messages .filter { it.event?.subject() != null } .sortedBy { it.createdAt() } .lastOrNull() ?.let { - subject = it.event?.subject() + subject.tryEmit(it.event?.subject()) subjectCreatedAt = it.createdAt() } + return true } + return false } - fun senderIntersects(keySet: Set): Boolean = authors.any { it.pubkeyHex in keySet } + fun senderIntersects(keySet: Set): Boolean = activeSenders.any { it.pubkeyHex in keySet } fun pruneMessagesToTheLatestOnly(): Set { - val sorted = roomMessages.sortedWith(DefaultFeedOrder) + val sorted = messages.sortedWith(DefaultFeedOrder) val toKeep = if ((sorted.firstOrNull()?.createdAt() ?: 0) > TimeUtils.oneWeekAgo()) { @@ -87,10 +103,10 @@ class Chatroom { } else { // Old messages, keep the last one. sorted.take(1).toSet() - } + sorted.filter { it.liveSet?.isInUse() ?: false } + sorted.filter { it.event !is PrivateDmEvent } + } + sorted.filter { it.flowSet?.isInUse() ?: false } + sorted.filter { it.event !is PrivateDmEvent } - val toRemove = roomMessages.minus(toKeep) - roomMessages = toKeep + val toRemove = messages.minus(toKeep) + messages = toKeep return toRemove } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privateChats/ChatroomList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privateChats/ChatroomList.kt new file mode 100644 index 0000000000..899495f67d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privateChats/ChatroomList.kt @@ -0,0 +1,116 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.privateChats + +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable +import com.vitorpamplona.quartz.utils.LargeCache +import kotlinx.collections.immutable.persistentSetOf + +class ChatroomList( + val ownerPubKey: HexKey, +) { + var rooms = LargeCache() + private set + + private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom = rooms.getOrCreate(key) { Chatroom() } + + fun getOrCreatePrivateChatroom(user: User): Chatroom { + val key = ChatroomKey(persistentSetOf(user.pubkeyHex)) + return getOrCreatePrivateChatroom(key) + } + + fun getOrCreatePrivateChatroom(key: ChatroomKey): Chatroom = getOrCreatePrivateChatroomSync(key) + + fun add( + event: ChatroomKeyable, + msg: Note, + ) { + if (event.isIncluded(ownerPubKey)) { + val key = event.chatroomKey(ownerPubKey) + addMessage(key, msg) + } + } + + fun delete( + event: ChatroomKeyable, + msg: Note, + ) { + if (event.isIncluded(ownerPubKey)) { + val key = event.chatroomKey(ownerPubKey) + removeMessage(key, msg) + } + } + + fun addMessage( + room: ChatroomKey, + msg: Note, + ) { + val privateChatroom = getOrCreatePrivateChatroom(room) + if (msg !in privateChatroom.messages) { + privateChatroom.addMessageSync(msg) + if (msg.author?.pubkeyHex == ownerPubKey) { + privateChatroom.ownerSentMessage = true + } + } + } + + fun addMessage( + user: User, + msg: Note, + ) { + val privateChatroom = getOrCreatePrivateChatroom(user) + if (msg !in privateChatroom.messages) { + privateChatroom.addMessageSync(msg) + if (msg.author?.pubkeyHex == ownerPubKey) { + privateChatroom.ownerSentMessage = true + } + } + } + + fun removeMessage( + user: User, + msg: Note, + ) { + val privateChatroom = getOrCreatePrivateChatroom(user) + if (msg in privateChatroom.messages) { + privateChatroom.removeMessageSync(msg) + } + } + + fun removeMessage( + room: ChatroomKey, + msg: Note, + ) { + val privateChatroom = getOrCreatePrivateChatroom(room) + if (msg in privateChatroom.messages) { + privateChatroom.removeMessageSync(msg) + } + } + + fun hasSentMessagesTo(key: ChatroomKey?): Boolean { + if (key == null) return false + return rooms.get(key)?.ownerSentMessage == true + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt new file mode 100644 index 0000000000..bdcb94ed6b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt @@ -0,0 +1,84 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.serverList + +import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListState +import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState +import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListState +import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListState +import com.vitorpamplona.quartz.nip72ModCommunities.follow.tags.CommunityTag +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.flow.stateIn +import kotlin.collections.map + +class MergedFollowListsState( + val kind3List: FollowListState, + val hashtagList: HashtagListState, + val geohashList: GeohashListState, + val communityList: CommunityListState, + val scope: CoroutineScope, +) { + fun mergeLists( + kind3: FollowListState.Kind3Follows, + hashtags: Set, + geohashes: Set, + community: Set, + ): FollowListState.Kind3Follows = + FollowListState.Kind3Follows( + kind3.authors, + kind3.authorsPlusMe, + kind3.hashtags + hashtags, + kind3.geotags + geohashes, + kind3.communities + community.map { it.address.toValue() }, + ) + + val flow: StateFlow = + combine( + kind3List.flow, + hashtagList.flow, + geohashList.flow, + communityList.flow, + ) { kind3, hashtag, geohash, community -> + mergeLists(kind3, hashtag, geohash, community) + }.onStart { + emit( + mergeLists( + kind3List.flow.value, + hashtagList.flow.value, + geohashList.flow.value, + communityList.flow.value, + ), + ) + }.sample(200) + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + kind3List.flow.value, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowPlusMineRelayListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowPlusMineRelayListsState.kt new file mode 100644 index 0000000000..2b0fa4a56f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowPlusMineRelayListsState.kt @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.serverList + +import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState +import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState +import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListOutboxOrProxyRelays +import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListState +import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +class MergedFollowPlusMineRelayListsState( + val followsOutboxOrProxyRelayList: FollowListOutboxOrProxyRelays, + val nip65RelayList: Nip65RelayListState, + val privateOutboxRelayList: PrivateStorageRelayListState, + val localRelayList: LocalRelayListState, + val broadcastRelayList: BroadcastRelayListState, + val indexerRelayList: IndexerRelayListState, + val scope: CoroutineScope, +) { + fun mergeLists(lists: Array>): Set = lists.reduce { acc, set -> acc + set } + + val flow: StateFlow> = + combine( + listOf( + followsOutboxOrProxyRelayList.flow, + nip65RelayList.outboxFlow, + nip65RelayList.inboxFlow, + privateOutboxRelayList.flow, + localRelayList.flow, + broadcastRelayList.flow, + indexerRelayList.flow, + ), + ::mergeLists, + ).onStart { + emit( + mergeLists( + arrayOf( + followsOutboxOrProxyRelayList.flow.value, + nip65RelayList.outboxFlow.value, + nip65RelayList.inboxFlow.value, + privateOutboxRelayList.flow.value, + localRelayList.flow.value, + broadcastRelayList.flow.value, + indexerRelayList.flow.value, + ), + ), + ) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + mergeLists( + arrayOf( + followsOutboxOrProxyRelayList.flow.value, + nip65RelayList.outboxFlow.value, + nip65RelayList.inboxFlow.value, + privateOutboxRelayList.flow.value, + localRelayList.flow.value, + broadcastRelayList.flow.value, + indexerRelayList.flow.value, + ), + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedServerListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedServerListState.kt new file mode 100644 index 0000000000..578e912ed7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedServerListState.kt @@ -0,0 +1,73 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.serverList + +import com.vitorpamplona.amethyst.model.nip96FileStorage.FileStorageServerListState +import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState +import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import org.czeal.rfc3986.URIReference + +class MergedServerListState( + val fileServers: FileStorageServerListState, + val blossomServers: BlossomServerListState, + val scope: CoroutineScope, +) { + fun host(url: String): String = + try { + URIReference.parse(url).host.value + } catch (e: Exception) { + url + } + + fun mergeServerList( + nip96: List?, + blossom: List?, + ): List { + val nip96servers = nip96?.map { ServerName(host(it), it, ServerType.NIP96) } ?: emptyList() + val blossomServers = blossom?.map { ServerName(host(it), it, ServerType.Blossom) } ?: emptyList() + + val result = (nip96servers + blossomServers).ifEmpty { DEFAULT_MEDIA_SERVERS } + + return result + ServerName("NIP95", "", ServerType.NIP95) + } + + val liveServerList: StateFlow> = + combine(fileServers.flow, blossomServers.flow) { nip96s, blossoms -> + mergeServerList(nip96s, blossoms) + }.onStart { + emit(mergeServerList(fileServers.flow.value, blossomServers.flow.value)) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + DEFAULT_MEDIA_SERVERS, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/TrustedRelayListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/TrustedRelayListsState.kt new file mode 100644 index 0000000000..743481c340 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/TrustedRelayListsState.kt @@ -0,0 +1,94 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.serverList + +import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState +import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState +import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.searchRelays.SearchRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays.TrustedRelayListState +import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +class TrustedRelayListsState( + val nip65RelayList: Nip65RelayListState, + val privateOutboxRelayList: PrivateStorageRelayListState, + val localRelayList: LocalRelayListState, + val dmRelayList: DmRelayListState, + val searchRelayListState: SearchRelayListState, + val trustedRelayList: TrustedRelayListState, + val broadcastRelayList: BroadcastRelayListState, + val scope: CoroutineScope, +) { + fun mergeLists(lists: Array>): Set = lists.reduce { acc, set -> acc + set } + + val flow: StateFlow> = + combine( + listOf( + nip65RelayList.allFlow, + privateOutboxRelayList.flow, + localRelayList.flow, + dmRelayList.flow, + searchRelayListState.flow, + trustedRelayList.flow, + broadcastRelayList.flow, + ), + ::mergeLists, + ).onStart { + emit( + mergeLists( + arrayOf( + nip65RelayList.allFlow.value, + privateOutboxRelayList.flow.value, + localRelayList.flow.value, + dmRelayList.flow.value, + searchRelayListState.flow.value, + trustedRelayList.flow.value, + broadcastRelayList.flow.value, + ), + ), + ) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + mergeLists( + arrayOf( + nip65RelayList.allFlow.value, + privateOutboxRelayList.flow.value, + localRelayList.flow.value, + dmRelayList.flow.value, + searchRelayListState.flow.value, + trustedRelayList.flow.value, + broadcastRelayList.flow.value, + ), + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/CommunityRelayLoader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/CommunityRelayLoader.kt new file mode 100644 index 0000000000..d4f4e44fea --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/CommunityRelayLoader.kt @@ -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.topNavFeeds + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.utils.mapOfSet +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +class CommunityRelayLoader { + companion object { + fun communitiesPerRelay( + communityNotes: Array, + cache: LocalCache, + ): Map> = + mapOfSet { + communityNotes.forEach { communityNote -> + val relays = + (communityNote.note.event as? CommunityDefinitionEvent) + ?.relayUrls() + ?.ifEmpty { null } + ?: cache.relayHints.hintsForAddress(communityNote.note.idHex) + + relays.forEach { + add(it, communityNote.note.idHex) + } + } + } + + fun communitiesPerRelaySnapshot( + communities: Set, + cache: LocalCache, + transformation: (Map>) -> T, + ): T { + val noteMetadata = + communities + .mapNotNull { addressId -> + cache + .checkGetOrCreateAddressableNote(addressId) + ?.flow() + ?.metadata + ?.stateFlow + ?.value + }.toTypedArray() + return transformation(communitiesPerRelay(noteMetadata, cache)) + } + + fun toCommunitiesPerRelayFlow( + communities: Set, + cache: LocalCache, + transformation: (Map>) -> T, + ): Flow { + val noteMetadataFlows = + communities.mapNotNull { addressId -> + cache + .checkGetOrCreateAddressableNote(addressId) + ?.flow() + ?.metadata + ?.stateFlow + } + + return combine(noteMetadataFlows) { communityNotes -> + transformation(communitiesPerRelay(communityNotes, cache)) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedDecryptionCaches.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedDecryptionCaches.kt new file mode 100644 index 0000000000..2ab2797652 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedDecryptionCaches.kt @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds + +import com.vitorpamplona.amethyst.model.nip51Lists.blockPeopleList.PeopleListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.muteList.MuteListDecryptionCache +import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListDecryptionCache + +class FeedDecryptionCaches( + val peopleListCache: PeopleListDecryptionCache, + val muteListCache: MuteListDecryptionCache, + val communityListCache: CommunityListDecryptionCache, + val hashtagCache: HashtagListDecryptionCache, + val geohashCache: GeohashListDecryptionCache, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt new file mode 100644 index 0000000000..a825fed969 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt @@ -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.topNavFeeds + +import com.vitorpamplona.amethyst.model.ALL_FOLLOWS +import com.vitorpamplona.amethyst.model.AROUND_ME +import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListState +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsFeedFlow +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.AroundMeFeedFlow +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalFeedFlow +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.NoteFeedFlow +import com.vitorpamplona.amethyst.model.topNavFeeds.unknown.UnknownFeedFlow +import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest + +class FeedTopNavFilterState( + val feedFilterListName: MutableStateFlow, + val allFollows: StateFlow, + val locationFlow: StateFlow, + val followsRelays: StateFlow>, + val blockedRelays: StateFlow>, + val proxyRelays: StateFlow>, + val caches: FeedDecryptionCaches, + val signer: NostrSigner, + val scope: CoroutineScope, +) { + fun loadFlowsFor(listName: String): IFeedFlowsType = + when (listName) { + GLOBAL_FOLLOWS -> GlobalFeedFlow(followsRelays, proxyRelays) + ALL_FOLLOWS -> AllFollowsFeedFlow(allFollows, followsRelays, blockedRelays, proxyRelays) + AROUND_ME -> AroundMeFeedFlow(locationFlow, followsRelays, proxyRelays) + else -> { + val note = LocalCache.checkGetOrCreateAddressableNote(listName) + if (note != null) { + NoteFeedFlow(note.flow().metadata.stateFlow, signer, followsRelays, blockedRelays, proxyRelays, caches) + } else { + UnknownFeedFlow(listName) + } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow = + feedFilterListName + .transformLatest { listName -> + emitAll(loadFlowsFor(listName).flow()) + }.onStart { + loadFlowsFor(feedFilterListName.value).startValue(this) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + loadFlowsFor(feedFilterListName.value).startValue(), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayServiceStatus.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedFlowsType.kt similarity index 78% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayServiceStatus.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedFlowsType.kt index a4f6fad1ad..150fa4c992 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayServiceStatus.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedFlowsType.kt @@ -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,16 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relays +package com.vitorpamplona.amethyst.model.topNavFeeds -import com.vitorpamplona.ammolite.relays.NostrClient +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector -sealed class RelayServiceStatus { - data class Active( - val client: NostrClient, - ) : RelayServiceStatus() +interface IFeedFlowsType { + fun flow(): Flow - object Off : RelayServiceStatus() + fun startValue(): IFeedTopNavFilter - object Connecting : RelayServiceStatus() + suspend fun startValue(collector: FlowCollector) } diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/IPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavFilter.kt similarity index 69% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/IPerRelayFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavFilter.kt index f246b02195..2ec3d0a43d 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/IPerRelayFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavFilter.kt @@ -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,23 +18,19 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.ammolite.relays.filters +package com.vitorpamplona.amethyst.model.topNavFeeds +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.flow.Flow -interface IPerRelayFilter { - fun toRelay(forRelay: String): Filter +interface IFeedTopNavFilter { + fun matchAuthor(pubkey: HexKey): Boolean - fun toJson(forRelay: String): String + fun match(noteEvent: Event): Boolean - fun match( - event: Event, - forRelay: String, - ): Boolean + fun toPerRelayFlow(cache: LocalCache): Flow - fun toDebugJson(): String - - // This only exists because some relays confuse empty lists with null lists - fun isValidFor(url: String): Boolean + fun startValue(cache: LocalCache): IFeedTopNavPerRelayFilterSet } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/tags/AddressFollowTag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavPerRelayFilter.kt similarity index 84% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/tags/AddressFollowTag.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavPerRelayFilter.kt index cfe10b3a0c..4cbff4957a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/tags/AddressFollowTag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavPerRelayFilter.kt @@ -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,8 +18,6 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip02FollowList.tags +package com.vitorpamplona.amethyst.model.topNavFeeds -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag - -typealias AddressFollowTag = ATag +interface IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavPerRelayFilterSet.kt new file mode 100644 index 0000000000..5f922fbeb5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavPerRelayFilterSet.kt @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds + +interface IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/MergedTopFeedAuthorListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/MergedTopFeedAuthorListsState.kt new file mode 100644 index 0000000000..fb860ae395 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/MergedTopFeedAuthorListsState.kt @@ -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.topNavFeeds + +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.mapOfSet +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +class MergedTopFeedAuthorListsState( + val homeNavFilter: StateFlow, + val videoNavFilter: StateFlow, + val discoveryNavFilter: StateFlow, + val notificationNavFilter: StateFlow, + val scope: CoroutineScope, +) { + fun authorList(navFilter: IFeedTopNavPerRelayFilterSet): Map?> = + when (navFilter) { + is AllCommunitiesTopNavPerRelayFilterSet -> emptyMap() + is AllFollowsTopNavPerRelayFilterSet -> navFilter.set.mapValues { it.value.authors } + is AuthorsTopNavPerRelayFilterSet -> navFilter.set.mapValues { it.value.authors } + is GlobalTopNavPerRelayFilterSet -> emptyMap() + is HashtagTopNavPerRelayFilterSet -> emptyMap() + is LocationTopNavPerRelayFilterSet -> emptyMap() + is MutedAuthorsTopNavPerRelayFilterSet -> navFilter.set.mapValues { it.value.authors } + is SingleCommunityTopNavPerRelayFilterSet -> navFilter.set.mapValues { it.value.authors } + else -> emptyMap() + } + + fun mergeLists( + homeNavFilter: IFeedTopNavPerRelayFilterSet, + videoNavFilter: IFeedTopNavPerRelayFilterSet, + discoveryNavFilter: IFeedTopNavPerRelayFilterSet, + notificationNavFilter: IFeedTopNavPerRelayFilterSet, + ): Map> = + mapOfSet { + authorList(homeNavFilter).forEach { (relay, authors) -> + authors?.let { add(relay, authors) } + } + + authorList(videoNavFilter).forEach { (relay, authors) -> + authors?.let { add(relay, authors) } + } + + authorList(discoveryNavFilter).forEach { (relay, authors) -> + authors?.let { add(relay, authors) } + } + + authorList(notificationNavFilter).forEach { (relay, authors) -> + authors?.let { add(relay, authors) } + } + } + + val flow: StateFlow>> = + combine( + homeNavFilter, + videoNavFilter, + discoveryNavFilter, + notificationNavFilter, + ::mergeLists, + ).onStart { + emit( + mergeLists( + homeNavFilter.value, + videoNavFilter.value, + discoveryNavFilter.value, + notificationNavFilter.value, + ), + ) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyMap(), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxLoaderState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxLoaderState.kt new file mode 100644 index 0000000000..257fdccf03 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxLoaderState.kt @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.unknown.UnknownTopNavPerRelayFilterSet +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest + +class OutboxLoaderState( + topNavFilter: StateFlow, + cache: LocalCache, + scope: CoroutineScope, +) { + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow = + topNavFilter + .transformLatest { filterSettings -> + emitAll(filterSettings.toPerRelayFlow(cache)) + }.onStart { + emit(topNavFilter.value.startValue(cache)) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Companion.Eagerly, + UnknownTopNavPerRelayFilterSet, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxRelayLoader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxRelayLoader.kt new file mode 100644 index 0000000000..26bee7d8e6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxRelayLoader.kt @@ -0,0 +1,94 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds + +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.utils.mapOfSet +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +class OutboxRelayLoader { + companion object { + private fun authorsPerRelay( + outboxRelayNotes: Array, + cache: LocalCache, + ): Map> = + mapOfSet { + outboxRelayNotes.forEach { outboxNote -> + val note = outboxNote.note + + val authorHex = + if (note is AddressableNote) { + note.address.pubKeyHex + } else { + note.author?.pubkeyHex + } + + if (authorHex != null) { + val relays = + (outboxNote.note.event as? AdvertisedRelayListEvent)?.writeRelaysNorm()?.ifEmpty { null } + ?: cache.relayHints.hintsForKey(authorHex) + + relays.forEach { + add(it, authorHex) + } + } + } + } + + fun authorsPerRelaySnapshot( + authors: Set, + cache: LocalCache, + transformation: (Map>) -> T, + ): T { + val noteMetadata = + authors + .map { pubkeyHex -> + cache + .getOrCreateAddressableNote(AdvertisedRelayListEvent.createAddress(pubkeyHex)) + .flow() + .metadata.stateFlow.value + }.toTypedArray() + return transformation(authorsPerRelay(noteMetadata, cache)) + } + + fun toAuthorsPerRelayFlow( + authors: Set, + cache: LocalCache, + transformation: (Map>) -> T, + ): Flow { + val noteMetadataFlows = + authors.map { pubkeyHex -> + val note = cache.getOrCreateAddressableNote(AdvertisedRelayListEvent.createAddress(pubkeyHex)) + note.flow().metadata.stateFlow + } + + return combine(noteMetadataFlows) { outboxRelays -> + transformation(authorsPerRelay(outboxRelays, cache)) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavFilter.kt new file mode 100644 index 0000000000..38c4911e50 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavFilter.kt @@ -0,0 +1,139 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.allFollows + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.CommunityRelayLoader +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNotes +import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHashes +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHashes +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId +import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlin.collections.associateWith + +/** + * This is a big OR filter on all fields. + */ +@Immutable +class AllFollowsByOutboxTopNavFilter( + val authors: Set? = null, + val hashtags: Set? = null, + val geotags: Set? = null, + val communities: Set? = null, + val defaultRelays: StateFlow>, + val blockedRelays: StateFlow>, +) : IFeedTopNavFilter { + val geotagScopes: Set? = geotags?.mapTo(mutableSetOf()) { GeohashId.Companion.toScope(it) } + val hashtagScopes: Set? = hashtags?.mapTo(mutableSetOf()) { HashtagId.Companion.toScope(it) } + + override fun matchAuthor(pubkey: HexKey): Boolean = authors == null || pubkey in authors + + override fun match(noteEvent: Event): Boolean = + if (noteEvent is LiveActivitiesEvent) { + (authors != null && noteEvent.participantsIntersect(authors)) || + (hashtags != null && noteEvent.isTaggedHashes(hashtags)) || + (geotags != null && noteEvent.isTaggedGeoHashes(geotags)) || + (communities != null && noteEvent.isTaggedAddressableNotes(communities)) + } else if (noteEvent is CommentEvent) { + // ignore follows and checks only the root scope + (authors != null && noteEvent.pubKey in authors) || + (hashtags != null && noteEvent.isTaggedHashes(hashtags)) || + (hashtagScopes != null && noteEvent.isTaggedScopes(hashtagScopes)) || + (geotags != null && noteEvent.isTaggedGeoHashes(geotags)) || + (geotagScopes != null && noteEvent.isTaggedScopes(geotagScopes)) || + (communities != null && noteEvent.isTaggedAddressableNotes(communities)) + } else { + (authors != null && noteEvent.pubKey in authors) || + (hashtags != null && noteEvent.isTaggedHashes(hashtags)) || + (geotags != null && noteEvent.isTaggedGeoHashes(geotags)) || + (communities != null && noteEvent.isTaggedAddressableNotes(communities)) + } + + override fun toPerRelayFlow(cache: LocalCache): Flow { + val authorsPerRelay = + if (authors != null) { + OutboxRelayLoader.toAuthorsPerRelayFlow(authors, cache) { it } + } else { + MutableStateFlow(emptyMap()) + } + val communitiesPerRelay = + if (communities != null) { + CommunityRelayLoader.toCommunitiesPerRelayFlow(communities, cache) { it } + } else { + MutableStateFlow(emptyMap()) + } + + return combine(authorsPerRelay, communitiesPerRelay, defaultRelays, blockedRelays) { perRelayAuthors, perRelayCommunities, default, blockedRelays -> + val allRelays = (perRelayAuthors.keys + perRelayCommunities.keys).filter { it !in blockedRelays }.ifEmpty { default } + + AllFollowsTopNavPerRelayFilterSet( + allRelays.associateWith { + AllFollowsTopNavPerRelayFilter( + authors = perRelayAuthors[it], + hashtags = hashtags, + geotags = geotags, + communities = perRelayCommunities[it], + ) + }, + ) + } + } + + override fun startValue(cache: LocalCache): AllFollowsTopNavPerRelayFilterSet { + val authorsPerRelay = + if (authors != null) { + OutboxRelayLoader.authorsPerRelaySnapshot(authors, cache) { it } + } else { + emptyMap() + } + val communitiesPerRelay = + if (communities != null) { + CommunityRelayLoader.communitiesPerRelaySnapshot(communities, cache) { it } + } else { + emptyMap() + } + + val allRelays = (authorsPerRelay.keys + communitiesPerRelay.keys).filter { it !in blockedRelays.value }.ifEmpty { defaultRelays.value } + + return AllFollowsTopNavPerRelayFilterSet( + allRelays.associateWith { + AllFollowsTopNavPerRelayFilter( + authors = authorsPerRelay[it], + hashtags = hashtags, + geotags = geotags, + communities = communitiesPerRelay[it], + ) + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByProxyTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByProxyTopNavFilter.kt new file mode 100644 index 0000000000..348c101953 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByProxyTopNavFilter.kt @@ -0,0 +1,105 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.allFollows + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNotes +import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHashes +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHashes +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId +import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.collections.associateWith + +/** + * This is a big OR filter on all fields. + */ +@Immutable +class AllFollowsByProxyTopNavFilter( + val authors: Set? = null, + val hashtags: Set? = null, + val geotags: Set? = null, + val communities: Set? = null, + val proxyRelays: Set, +) : IFeedTopNavFilter { + val geotagScopes: Set? = geotags?.mapTo(mutableSetOf()) { GeohashId.Companion.toScope(it) } + val hashtagScopes: Set? = hashtags?.mapTo(mutableSetOf()) { HashtagId.Companion.toScope(it) } + + override fun matchAuthor(pubkey: HexKey): Boolean = authors == null || pubkey in authors + + override fun match(noteEvent: Event): Boolean = + if (noteEvent is LiveActivitiesEvent) { + (authors != null && noteEvent.participantsIntersect(authors)) || + (hashtags != null && noteEvent.isTaggedHashes(hashtags)) || + (geotags != null && noteEvent.isTaggedGeoHashes(geotags)) || + (communities != null && noteEvent.isTaggedAddressableNotes(communities)) + } else if (noteEvent is CommentEvent) { + // ignore follows and checks only the root scope + (authors != null && noteEvent.pubKey in authors) || + (hashtags != null && noteEvent.isTaggedHashes(hashtags)) || + (hashtagScopes != null && noteEvent.isTaggedScopes(hashtagScopes)) || + (geotags != null && noteEvent.isTaggedGeoHashes(geotags)) || + (geotagScopes != null && noteEvent.isTaggedScopes(geotagScopes)) || + (communities != null && noteEvent.isTaggedAddressableNotes(communities)) + } else { + (authors != null && noteEvent.pubKey in authors) || + (hashtags != null && noteEvent.isTaggedHashes(hashtags)) || + (geotags != null && noteEvent.isTaggedGeoHashes(geotags)) || + (communities != null && noteEvent.isTaggedAddressableNotes(communities)) + } + + // forces the use of the Proxy on all connections, replacing the outbox model. + override fun toPerRelayFlow(cache: LocalCache): Flow = + MutableStateFlow( + AllFollowsTopNavPerRelayFilterSet( + proxyRelays.associateWith { + AllFollowsTopNavPerRelayFilter( + authors = authors, + hashtags = hashtags, + geotags = geotags, + communities = communities, + ) + }, + ), + ) + + override fun startValue(cache: LocalCache): AllFollowsTopNavPerRelayFilterSet { + // forces the use of the Proxy on all connections, replacing the outbox model. + return AllFollowsTopNavPerRelayFilterSet( + proxyRelays.associateWith { + AllFollowsTopNavPerRelayFilter( + authors = authors, + hashtags = hashtags, + geotags = geotags, + communities = communities, + ) + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsFeedFlow.kt new file mode 100644 index 0000000000..f233cea57d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsFeedFlow.kt @@ -0,0 +1,75 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.allFollows + +import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListState +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine + +class AllFollowsFeedFlow( + val allFollows: StateFlow, + val followsRelays: StateFlow>, + val blockedRelays: StateFlow>, + val proxyRelays: StateFlow>, +) : IFeedFlowsType { + fun convert( + kind3: FollowListState.Kind3Follows?, + proxyRelays: Set, + ): IFeedTopNavFilter = + if (kind3 != null) { + if (proxyRelays.isEmpty()) { + AllFollowsByOutboxTopNavFilter( + authors = kind3.authors, + hashtags = kind3.hashtags, + geotags = kind3.geotags, + communities = kind3.communities, + defaultRelays = followsRelays, + blockedRelays = blockedRelays, + ) + } else { + AllFollowsByProxyTopNavFilter( + authors = kind3.authors, + hashtags = kind3.hashtags, + geotags = kind3.geotags, + communities = kind3.communities, + proxyRelays = proxyRelays, + ) + } + } else { + AllFollowsByOutboxTopNavFilter( + authors = emptySet(), + defaultRelays = followsRelays, + blockedRelays = blockedRelays, + ) + } + + override fun flow() = combine(allFollows, proxyRelays, ::convert) + + override fun startValue(): IFeedTopNavFilter = convert(allFollows.value, proxyRelays.value) + + override suspend fun startValue(collector: FlowCollector) { + collector.emit(startValue()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilter.kt new file mode 100644 index 0000000000..af5595c048 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilter.kt @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.allFollows + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId +import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId + +/** + * This is a big OR filter. + */ +@Immutable +class AllFollowsTopNavPerRelayFilter( + val authors: Set? = null, + val hashtags: Set? = null, + val geotags: Set? = null, + val communities: Set? = null, +) : IFeedTopNavPerRelayFilter { + val geotagScopes: Set? = geotags?.mapTo(mutableSetOf()) { GeohashId.toScope(it) } + val hashtagScopes: Set? = hashtags?.mapTo(mutableSetOf()) { HashtagId.toScope(it) } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilterSet.kt new file mode 100644 index 0000000000..032b4590cf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilterSet.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.allFollows + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class AllFollowsTopNavPerRelayFilterSet( + val set: Map, +) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt new file mode 100644 index 0000000000..028931a18e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe + +import com.fonfon.kgeohash.GeoHash + +fun compute50kmLine(geoHash: GeoHash): List { + val hashes = mutableListOf() + + hashes.add(geoHash.toString()) + + var currentGeoHash = geoHash + repeat(5) { + currentGeoHash = currentGeoHash.westernNeighbour + hashes.add(currentGeoHash.toString()) + } + + currentGeoHash = geoHash + repeat(5) { + currentGeoHash = currentGeoHash.easternNeighbour + hashes.add(currentGeoHash.toString()) + } + + return hashes +} + +fun compute50kmRange(geoHash: GeoHash): List { + val hashes = mutableListOf() + + hashes.addAll(compute50kmLine(geoHash)) + + var currentGeoHash = geoHash + repeat(5) { + currentGeoHash = currentGeoHash.northernNeighbour + hashes.addAll(compute50kmLine(currentGeoHash)) + } + + currentGeoHash = geoHash + repeat(5) { + currentGeoHash = currentGeoHash.southernNeighbour + hashes.addAll(compute50kmLine(currentGeoHash)) + } + + return hashes +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeFeedFlow.kt new file mode 100644 index 0000000000..a0c66edc0d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeFeedFlow.kt @@ -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.topNavFeeds.aroundMe + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine + +class AroundMeFeedFlow( + val location: StateFlow, + val outboxRelays: StateFlow>, + val proxyRelays: StateFlow>, +) : IFeedFlowsType { + fun convert( + result: LocationState.LocationResult, + outboxRelays: Set, + proxyRelays: Set, + ): LocationTopNavFilter = + if (result is LocationState.LocationResult.Success) { + // 2 neighbors deep = 25x25km + LocationTopNavFilter( + geotags = compute50kmRange(result.geoHash).toSet(), + relayList = proxyRelays.ifEmpty { outboxRelays }, + ) + } else { + // empty feed until we have a successful geohash + LocationTopNavFilter( + geotags = emptySet(), + relayList = proxyRelays.ifEmpty { outboxRelays }, + ) + } + + override fun flow() = combine(location, outboxRelays, proxyRelays, ::convert) + + override fun startValue(): LocationTopNavFilter = convert(location.value, outboxRelays.value, proxyRelays.value) + + override suspend fun startValue(collector: FlowCollector) { + collector.emit(startValue()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavFilter.kt new file mode 100644 index 0000000000..6dce8b3cbe --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavFilter.kt @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHashes +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +@Immutable +class LocationTopNavFilter( + val geotags: Set, + val relayList: Set, +) : IFeedTopNavFilter { + val geotagScopes: Set = geotags.mapTo(mutableSetOf()) { GeohashId.toScope(it) } + + override fun matchAuthor(pubkey: HexKey): Boolean = true + + override fun match(noteEvent: Event): Boolean { + if (geotags.isEmpty()) return false + + return if (noteEvent is CommentEvent) { + noteEvent.isTaggedGeoHashes(geotags) || + noteEvent.isTaggedScopes(geotagScopes) + } else { + noteEvent.isTaggedGeoHashes(geotags) + } + } + + override fun toPerRelayFlow(cache: LocalCache): Flow = + MutableStateFlow( + LocationTopNavPerRelayFilterSet(relayList.associateWith { LocationTopNavPerRelayFilter(geotags) }), + ) + + override fun startValue(cache: LocalCache): LocationTopNavPerRelayFilterSet = + LocationTopNavPerRelayFilterSet( + relayList.associateWith { LocationTopNavPerRelayFilter(geotags) }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilter.kt new file mode 100644 index 0000000000..219c107192 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilter.kt @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId + +@Immutable +class LocationTopNavPerRelayFilter( + val geotags: Set, +) : IFeedTopNavPerRelayFilter { + val geotagScopes: Set = geotags.mapTo(mutableSetOf()) { GeohashId.toScope(it) } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilterSet.kt new file mode 100644 index 0000000000..85564aac7c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilterSet.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class LocationTopNavPerRelayFilterSet( + val set: Map, +) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalFeedFlow.kt new file mode 100644 index 0000000000..3aa72e1f88 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalFeedFlow.kt @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.global + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class GlobalFeedFlow( + val outboxRelays: StateFlow>, + val proxyRelays: StateFlow>, +) : IFeedFlowsType { + val default = GlobalTopNavFilter(outboxRelays, proxyRelays) + + override fun flow() = MutableStateFlow(default) + + override fun startValue(): GlobalTopNavFilter = default + + override suspend fun startValue(collector: FlowCollector) { + collector.emit(startValue()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavFilter.kt new file mode 100644 index 0000000000..5bf4028349 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavFilter.kt @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.global + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlin.collections.associateWith +import kotlin.collections.isNotEmpty + +@Immutable +class GlobalTopNavFilter( + val outboxRelays: StateFlow>, + val proxyRelays: StateFlow>, +) : IFeedTopNavFilter { + override fun matchAuthor(pubkey: HexKey): Boolean = true + + override fun match(noteEvent: Event) = true + + override fun toPerRelayFlow(cache: LocalCache): Flow = + combine(outboxRelays, proxyRelays) { outboxRelays, proxyRelays -> + if (proxyRelays.isNotEmpty()) { + GlobalTopNavPerRelayFilterSet(proxyRelays.associateWith { GlobalTopNavPerRelayFilter }) + } else { + GlobalTopNavPerRelayFilterSet(outboxRelays.associateWith { GlobalTopNavPerRelayFilter }) + } + } + + override fun startValue(cache: LocalCache): GlobalTopNavPerRelayFilterSet = + if (proxyRelays.value.isNotEmpty()) { + GlobalTopNavPerRelayFilterSet(proxyRelays.value.associateWith { GlobalTopNavPerRelayFilter }) + } else { + GlobalTopNavPerRelayFilterSet(outboxRelays.value.associateWith { GlobalTopNavPerRelayFilter }) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavPerRelayFilter.kt new file mode 100644 index 0000000000..772a298dca --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavPerRelayFilter.kt @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.global + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter + +@Immutable +object GlobalTopNavPerRelayFilter : IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavPerRelayFilterSet.kt new file mode 100644 index 0000000000..2e30e5dc53 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavPerRelayFilterSet.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.global + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class GlobalTopNavPerRelayFilterSet( + val set: Map, +) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavFilter.kt new file mode 100644 index 0000000000..47235adc20 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavFilter.kt @@ -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.topNavFeeds.hashtag + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHashes +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +@Immutable +class HashtagTopNavFilter( + val hashtags: Set, + val relayList: Set, +) : IFeedTopNavFilter { + val hashtagScopes: Set = hashtags.mapTo(mutableSetOf()) { HashtagId.toScope(it) } + + override fun matchAuthor(pubkey: HexKey): Boolean = true + + override fun match(noteEvent: Event): Boolean = + if (noteEvent is CommentEvent) { + noteEvent.isTaggedHashes(hashtags) || noteEvent.isTaggedScopes(hashtagScopes) + } else { + noteEvent.isTaggedHashes(hashtags) + } + + override fun toPerRelayFlow(cache: LocalCache): Flow = + MutableStateFlow( + HashtagTopNavPerRelayFilterSet( + relayList.associateWith { HashtagTopNavPerRelayFilter(hashtags) }, + ), + ) + + override fun startValue(cache: LocalCache): HashtagTopNavPerRelayFilterSet = + HashtagTopNavPerRelayFilterSet( + relayList.associateWith { HashtagTopNavPerRelayFilter(hashtags) }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilter.kt new file mode 100644 index 0000000000..8c5a0406f2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilter.kt @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.hashtag + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter +import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId + +@Immutable +class HashtagTopNavPerRelayFilter( + val hashtags: Set, +) : IFeedTopNavPerRelayFilter { + val hashtagScopes: Set = hashtags.mapTo(mutableSetOf()) { HashtagId.toScope(it) } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilterSet.kt new file mode 100644 index 0000000000..35c5481b0c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilterSet.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.hashtag + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class HashtagTopNavPerRelayFilterSet( + val set: Map, +) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/NoteFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/NoteFeedFlow.kt new file mode 100644 index 0000000000..1bd864bb43 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/NoteFeedFlow.kt @@ -0,0 +1,262 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased + +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.amethyst.model.topNavFeeds.FeedDecryptionCaches +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByProxyTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +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.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combineTransform + +class NoteFeedFlow( + val metadataFlow: StateFlow, + val signer: NostrSigner, + val outboxRelays: StateFlow>, + val blockedRelays: StateFlow>, + val proxyRelays: StateFlow>, + val caches: FeedDecryptionCaches, +) : IFeedFlowsType { + fun processByOutbox( + noteEvent: Event, + outboxRelays: Set, + ): IFeedTopNavFilter = + when (noteEvent) { + is PeopleListEvent -> { + if (noteEvent.dTag() == PeopleListEvent.Companion.BLOCK_LIST_D_TAG) { + MutedAuthorsByOutboxTopNavFilter(caches.peopleListCache.cachedUserIdSet(noteEvent), blockedRelays) + } else { + AuthorsByOutboxTopNavFilter(caches.peopleListCache.cachedUserIdSet(noteEvent), blockedRelays) + } + } + is MuteListEvent -> { + MutedAuthorsByOutboxTopNavFilter(caches.muteListCache.cachedUserIdSet(noteEvent), blockedRelays) + } + is FollowListEvent -> { + AuthorsByOutboxTopNavFilter(noteEvent.followIdSet(), blockedRelays) + } + is CommunityListEvent -> { + AllCommunitiesTopNavFilter(caches.communityListCache.cachedCommunityIdSet(noteEvent), blockedRelays) + } + is HashtagListEvent -> { + HashtagTopNavFilter(caches.hashtagCache.cachedHashtags(noteEvent), outboxRelays) + } + is GeohashListEvent -> { + LocationTopNavFilter(caches.geohashCache.cachedGeohashes(noteEvent), outboxRelays) + } + is CommunityDefinitionEvent -> { + SingleCommunityTopNavFilter( + community = noteEvent.addressTag(), + authors = noteEvent.moderatorKeys().toSet().ifEmpty { null }, + relays = noteEvent.relayUrls().toSet(), + blockedRelays = blockedRelays, + ) + } + else -> AuthorsByOutboxTopNavFilter(emptySet(), blockedRelays) + } + + suspend fun FlowCollector.processByOutbox( + noteEvent: Event, + outboxRelays: Set, + ) { + when (noteEvent) { + is PeopleListEvent -> { + if (noteEvent.dTag() == PeopleListEvent.Companion.BLOCK_LIST_D_TAG) { + emit(MutedAuthorsByOutboxTopNavFilter(caches.peopleListCache.userIdSet(noteEvent), blockedRelays)) + } else { + emit(AuthorsByOutboxTopNavFilter(caches.peopleListCache.userIdSet(noteEvent), blockedRelays)) + } + } + is MuteListEvent -> { + emit(MutedAuthorsByOutboxTopNavFilter(caches.muteListCache.mutedUserIdSet(noteEvent), blockedRelays)) + } + is FollowListEvent -> { + emit(AuthorsByOutboxTopNavFilter(noteEvent.followIdSet(), blockedRelays)) + } + is CommunityListEvent -> { + emit(AllCommunitiesTopNavFilter(caches.communityListCache.communityIdSet(noteEvent), blockedRelays)) + } + is HashtagListEvent -> { + emit(HashtagTopNavFilter(caches.hashtagCache.hashtags(noteEvent), outboxRelays)) + } + is GeohashListEvent -> { + emit(LocationTopNavFilter(caches.geohashCache.geohashes(noteEvent), outboxRelays)) + } + is CommunityDefinitionEvent -> { + emit( + SingleCommunityTopNavFilter( + community = noteEvent.addressTag(), + authors = noteEvent.moderatorKeys().toSet().ifEmpty { null }, + relays = noteEvent.relayUrls().toSet(), + blockedRelays = blockedRelays, + ), + ) + } + else -> + emit( + AuthorsByOutboxTopNavFilter(emptySet(), blockedRelays), + ) + } + } + + fun processByProxy( + noteEvent: Event, + proxyRelays: Set, + ): IFeedTopNavFilter = + when (noteEvent) { + is PeopleListEvent -> { + if (noteEvent.dTag() == PeopleListEvent.Companion.BLOCK_LIST_D_TAG) { + MutedAuthorsByProxyTopNavFilter(caches.peopleListCache.cachedUserIdSet(noteEvent), proxyRelays) + } else { + AuthorsByProxyTopNavFilter(caches.peopleListCache.cachedUserIdSet(noteEvent), proxyRelays) + } + } + is MuteListEvent -> { + MutedAuthorsByProxyTopNavFilter(caches.muteListCache.cachedUserIdSet(noteEvent), proxyRelays) + } + is FollowListEvent -> { + AuthorsByProxyTopNavFilter(noteEvent.followIdSet(), proxyRelays) + } + is CommunityListEvent -> { + AllCommunitiesTopNavFilter(caches.communityListCache.cachedCommunityIdSet(noteEvent), blockedRelays) + } + is HashtagListEvent -> { + HashtagTopNavFilter(caches.hashtagCache.cachedHashtags(noteEvent), proxyRelays) + } + is GeohashListEvent -> { + LocationTopNavFilter(caches.geohashCache.cachedGeohashes(noteEvent), proxyRelays) + } + is CommunityDefinitionEvent -> { + SingleCommunityTopNavFilter( + community = noteEvent.addressTag(), + authors = noteEvent.moderatorKeys().toSet().ifEmpty { null }, + relays = noteEvent.relayUrls().toSet(), + blockedRelays = blockedRelays, + ) + } + else -> AuthorsByProxyTopNavFilter(emptySet(), proxyRelays) + } + + suspend fun FlowCollector.processByProxy( + noteEvent: Event, + proxyRelays: Set, + ) { + when (noteEvent) { + is PeopleListEvent -> { + if (noteEvent.dTag() == PeopleListEvent.Companion.BLOCK_LIST_D_TAG) { + emit(MutedAuthorsByProxyTopNavFilter(caches.peopleListCache.userIdSet(noteEvent), proxyRelays)) + } else { + emit(AuthorsByProxyTopNavFilter(caches.peopleListCache.userIdSet(noteEvent), proxyRelays)) + } + } + is MuteListEvent -> { + emit(MutedAuthorsByProxyTopNavFilter(caches.muteListCache.mutedUserIdSet(noteEvent), proxyRelays)) + } + is FollowListEvent -> { + emit(AuthorsByProxyTopNavFilter(noteEvent.followIdSet(), proxyRelays)) + } + is CommunityListEvent -> { + emit(AllCommunitiesTopNavFilter(caches.communityListCache.communityIdSet(noteEvent), blockedRelays)) + } + is HashtagListEvent -> { + emit(HashtagTopNavFilter(caches.hashtagCache.hashtags(noteEvent), proxyRelays)) + } + is GeohashListEvent -> { + emit(LocationTopNavFilter(caches.geohashCache.geohashes(noteEvent), proxyRelays)) + } + is CommunityDefinitionEvent -> { + emit( + SingleCommunityTopNavFilter( + community = noteEvent.addressTag(), + authors = noteEvent.moderatorKeys().toSet().ifEmpty { null }, + relays = noteEvent.relayUrls().toSet(), + blockedRelays = blockedRelays, + ), + ) + } + else -> + emit( + AuthorsByProxyTopNavFilter(emptySet(), proxyRelays), + ) + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun flow() = + combineTransform(metadataFlow, outboxRelays, proxyRelays) { noteState, outboxRelays, proxyRelays -> + val noteEvent = noteState?.note?.event + if (noteEvent == null) { + AuthorsByOutboxTopNavFilter(emptySet(), blockedRelays) + } else { + if (proxyRelays.isEmpty()) { + processByOutbox(noteEvent, outboxRelays) + } else { + processByProxy(noteEvent, proxyRelays) + } + } + } + + override fun startValue(): IFeedTopNavFilter { + val noteEvent = metadataFlow.value?.note?.event + return if (noteEvent == null) { + return AuthorsByOutboxTopNavFilter(emptySet(), blockedRelays) + } else { + if (proxyRelays.value.isEmpty()) { + processByOutbox(noteEvent, outboxRelays.value) + } else { + processByProxy(noteEvent, proxyRelays.value) + } + } + } + + override suspend fun startValue(collector: FlowCollector) { + val noteEvent = metadataFlow.value?.note?.event + if (noteEvent == null) { + collector.emit(AuthorsByOutboxTopNavFilter(emptySet(), blockedRelays)) + } else { + if (proxyRelays.value.isEmpty()) { + collector.processByOutbox(noteEvent, outboxRelays.value) + } else { + collector.processByProxy(noteEvent, proxyRelays.value) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavFilter.kt new file mode 100644 index 0000000000..40debbe42d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavFilter.kt @@ -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.topNavFeeds.noteBased.allcommunities + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.CommunityRelayLoader +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNotes +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine + +@Immutable +class AllCommunitiesTopNavFilter( + val communities: Set, + val blockedRelays: StateFlow>, +) : IFeedTopNavFilter { + override fun matchAuthor(pubkey: HexKey): Boolean = true + + override fun match(noteEvent: Event): Boolean = noteEvent.isTaggedAddressableNotes(communities) + + fun convert(map: Map>) = + AllCommunitiesTopNavPerRelayFilterSet( + map.mapValues { AllCommunitiesTopNavPerRelayFilter(it.value) }, + ) + + override fun toPerRelayFlow(cache: LocalCache): Flow { + val communitiesPerRelay = CommunityRelayLoader.toCommunitiesPerRelayFlow(communities, cache) { it } + + return combine(communitiesPerRelay, blockedRelays) { communitiesPerRelay, blockedRelays -> + convert(communitiesPerRelay.minus(blockedRelays)) + } + } + + override fun startValue(cache: LocalCache): AllCommunitiesTopNavPerRelayFilterSet { + val communitiesPerRelay = CommunityRelayLoader.communitiesPerRelaySnapshot(communities, cache) { it } + + return convert(communitiesPerRelay.minus(blockedRelays.value)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilter.kt new file mode 100644 index 0000000000..dd532c3e99 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilter.kt @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter + +class AllCommunitiesTopNavPerRelayFilter( + val communities: Set, +) : IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilterSet.kt new file mode 100644 index 0000000000..061c40e5a9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilterSet.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class AllCommunitiesTopNavPerRelayFilterSet( + val set: Map, +) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavFilter.kt new file mode 100644 index 0000000000..63dc77fdbd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavFilter.kt @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine + +@Immutable +class AuthorsByOutboxTopNavFilter( + val authors: Set, + val blockedRelays: StateFlow>, +) : IFeedTopNavFilter { + override fun matchAuthor(pubkey: HexKey) = pubkey in authors + + override fun match(noteEvent: Event): Boolean = + if (noteEvent is LiveActivitiesEvent) { + noteEvent.participantsIntersect(authors) + } else { + noteEvent.pubKey in authors + } + + fun convert(map: Map>) = + AuthorsTopNavPerRelayFilterSet( + map.mapValues { AuthorsTopNavPerRelayFilter(it.value) }, + ) + + override fun toPerRelayFlow(cache: LocalCache): Flow { + val authorsPerRelay = OutboxRelayLoader.toAuthorsPerRelayFlow(authors, cache) { it } + + return combine(authorsPerRelay, blockedRelays) { authors, blocked -> + convert(authors.minus(blocked)) + } + } + + override fun startValue(cache: LocalCache): AuthorsTopNavPerRelayFilterSet { + val authorsPerRelay = OutboxRelayLoader.authorsPerRelaySnapshot(authors, cache) { it } + + return convert(authorsPerRelay.minus(blockedRelays.value)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByProxyTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByProxyTopNavFilter.kt new file mode 100644 index 0000000000..4489c55eec --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByProxyTopNavFilter.kt @@ -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.topNavFeeds.noteBased.author + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +@Immutable +class AuthorsByProxyTopNavFilter( + val authors: Set, + val proxyRelays: Set, +) : IFeedTopNavFilter { + override fun matchAuthor(pubkey: HexKey) = pubkey in authors + + override fun match(noteEvent: Event): Boolean = + if (noteEvent is LiveActivitiesEvent) { + noteEvent.participantsIntersect(authors) + } else { + noteEvent.pubKey in authors + } + + override fun toPerRelayFlow(cache: LocalCache): Flow = + MutableStateFlow( + AuthorsTopNavPerRelayFilterSet( + proxyRelays.associateWith { AuthorsTopNavPerRelayFilter(authors) }, + ), + ) + + override fun startValue(cache: LocalCache): AuthorsTopNavPerRelayFilterSet = + AuthorsTopNavPerRelayFilterSet( + proxyRelays.associateWith { AuthorsTopNavPerRelayFilter(authors) }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilter.kt new file mode 100644 index 0000000000..abd72b1ec5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilter.kt @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter + +@Immutable +class AuthorsTopNavPerRelayFilter( + val authors: Set, +) : IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilterSet.kt new file mode 100644 index 0000000000..8809e74964 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilterSet.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class AuthorsTopNavPerRelayFilterSet( + val set: Map, +) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavFilter.kt new file mode 100644 index 0000000000..7b090f141e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavFilter.kt @@ -0,0 +1,119 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNote +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map + +@Immutable +class SingleCommunityTopNavFilter( + val community: String, + val authors: Set?, + val relays: Set, + val blockedRelays: StateFlow>, +) : IFeedTopNavFilter { + override fun matchAuthor(pubkey: HexKey) = authors == null || pubkey in authors + + override fun match(noteEvent: Event): Boolean = + if (noteEvent is LiveActivitiesEvent) { + (authors != null && noteEvent.participantsIntersect(authors)) || noteEvent.isTaggedAddressableNote(community) + } else if (noteEvent is CommentEvent) { + (authors != null && noteEvent.pubKey in authors) || noteEvent.isTaggedAddressableNote(community) + } else { + (authors != null && noteEvent.pubKey in authors) || noteEvent.isTaggedAddressableNote(community) + } + + override fun toPerRelayFlow(cache: LocalCache): Flow { + // relay field takes priority + if (relays.isNotEmpty()) { + return blockedRelays.map { blocked -> + SingleCommunityTopNavPerRelayFilterSet( + relays.minus(blocked).associateWith { + SingleCommunityTopNavPerRelayFilter(community, authors) + }, + ) + } + } + + if (authors != null) { + // go by authors + val authorsPerRelay = OutboxRelayLoader.toAuthorsPerRelayFlow(authors, cache) { it } + + return combine(authorsPerRelay, blockedRelays) { authorsPerRelay, blocked -> + SingleCommunityTopNavPerRelayFilterSet( + authorsPerRelay.minus(blocked).mapValues { + SingleCommunityTopNavPerRelayFilter(community, it.value) + }, + ) + } + } + + // go by hints + return blockedRelays.map { blocked -> + SingleCommunityTopNavPerRelayFilterSet( + cache.relayHints.hintsForAddress(community).minus(blocked).associateWith { + SingleCommunityTopNavPerRelayFilter(community, authors) + }, + ) + } + } + + override fun startValue(cache: LocalCache): SingleCommunityTopNavPerRelayFilterSet { + // relay field takes priority + if (relays.isNotEmpty()) { + return SingleCommunityTopNavPerRelayFilterSet( + relays.minus(blockedRelays.value).associateWith { + SingleCommunityTopNavPerRelayFilter(community, authors) + }, + ) + } + + if (authors != null) { + // go by authors + val authorsPerRelay = OutboxRelayLoader.authorsPerRelaySnapshot(authors, cache) { it } + + return SingleCommunityTopNavPerRelayFilterSet( + authorsPerRelay.minus(blockedRelays.value).mapValues { + SingleCommunityTopNavPerRelayFilter(community, it.value) + }, + ) + } + + // go by hints + return SingleCommunityTopNavPerRelayFilterSet( + cache.relayHints.hintsForAddress(community).minus(blockedRelays.value).associateWith { + SingleCommunityTopNavPerRelayFilter(community, authors) + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/proxyPort/ImageProxyFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilter.kt similarity index 74% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/proxyPort/ImageProxyFlow.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilter.kt index 857b5e9f20..596af8a4c5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/proxyPort/ImageProxyFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilter.kt @@ -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,17 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.proxyPort +package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community -import com.vitorpamplona.amethyst.Amethyst -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.onEach +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter -class ImageProxyFlow( - image: StateFlow, -) { - val status = - image.onEach { - Amethyst.instance.setImageLoader(it) - } -} +@Immutable +class SingleCommunityTopNavPerRelayFilter( + val community: String, + val authors: Set?, +) : IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilterSet.kt new file mode 100644 index 0000000000..c376579fa0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilterSet.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class SingleCommunityTopNavPerRelayFilterSet( + val set: Map, +) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavFilter.kt new file mode 100644 index 0000000000..b2a5b68ea5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavFilter.kt @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine + +@Immutable +class MutedAuthorsByOutboxTopNavFilter( + val authors: Set, + val blockedRelays: StateFlow>, +) : IFeedTopNavFilter { + override fun matchAuthor(pubkey: HexKey) = pubkey in authors + + override fun match(noteEvent: Event): Boolean = + if (noteEvent is LiveActivitiesEvent) { + noteEvent.participantsIntersect(authors) + } else { + noteEvent.pubKey in authors + } + + fun convert(map: Map>) = + MutedAuthorsTopNavPerRelayFilterSet( + map.mapValues { MutedAuthorsTopNavPerRelayFilter(it.value) }, + ) + + override fun toPerRelayFlow(cache: LocalCache): Flow { + val authorsPerRelay = OutboxRelayLoader.toAuthorsPerRelayFlow(authors, cache) { it } + + return combine(authorsPerRelay, blockedRelays) { authors, blocked -> + convert(authors.minus(blocked)) + } + } + + override fun startValue(cache: LocalCache): MutedAuthorsTopNavPerRelayFilterSet { + val authorsPerRelay = OutboxRelayLoader.authorsPerRelaySnapshot(authors, cache) { it } + + return convert(authorsPerRelay.minus(blockedRelays.value)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByProxyTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByProxyTopNavFilter.kt new file mode 100644 index 0000000000..36eea59a9d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByProxyTopNavFilter.kt @@ -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.topNavFeeds.noteBased.muted + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +@Immutable +class MutedAuthorsByProxyTopNavFilter( + val authors: Set, + val proxyRelays: Set, +) : IFeedTopNavFilter { + override fun matchAuthor(pubkey: HexKey) = pubkey in authors + + override fun match(noteEvent: Event): Boolean = + if (noteEvent is LiveActivitiesEvent) { + noteEvent.participantsIntersect(authors) + } else { + noteEvent.pubKey in authors + } + + override fun toPerRelayFlow(cache: LocalCache): Flow = + MutableStateFlow( + MutedAuthorsTopNavPerRelayFilterSet( + proxyRelays.associateWith { MutedAuthorsTopNavPerRelayFilter(authors) }, + ), + ) + + override fun startValue(cache: LocalCache): MutedAuthorsTopNavPerRelayFilterSet = + MutedAuthorsTopNavPerRelayFilterSet( + proxyRelays.associateWith { MutedAuthorsTopNavPerRelayFilter(authors) }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilter.kt new file mode 100644 index 0000000000..b56f83feba --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilter.kt @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter + +@Immutable +class MutedAuthorsTopNavPerRelayFilter( + val authors: Set, +) : IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilterSet.kt new file mode 100644 index 0000000000..69d4c1029b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilterSet.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class MutedAuthorsTopNavPerRelayFilterSet( + val set: Map, +) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownFeedFlow.kt new file mode 100644 index 0000000000..075f449ca8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownFeedFlow.kt @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.unknown + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableStateFlow + +class UnknownFeedFlow( + val feedName: String, +) : IFeedFlowsType { + override fun flow() = MutableStateFlow(UnknownTopNavFilter(feedName)) + + // empty feed + override fun startValue(): UnknownTopNavFilter = UnknownTopNavFilter(feedName) + + // empty feed + override suspend fun startValue(collector: FlowCollector) { + collector.emit(startValue()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavFilter.kt new file mode 100644 index 0000000000..c20e5793fa --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavFilter.kt @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.unknown + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +@Immutable +class UnknownTopNavFilter( + val feedName: String, +) : IFeedTopNavFilter { + override fun matchAuthor(pubkey: HexKey) = false + + override fun match(noteEvent: Event) = false + + override fun toPerRelayFlow(cache: LocalCache): Flow = MutableStateFlow(UnknownTopNavPerRelayFilterSet) + + override fun startValue(cache: LocalCache) = UnknownTopNavPerRelayFilterSet +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavPerRelayFilterSet.kt new file mode 100644 index 0000000000..556f596fc9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavPerRelayFilterSet.kt @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.unknown + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet + +object UnknownTopNavPerRelayFilterSet : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayEvaluation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayEvaluation.kt new file mode 100644 index 0000000000..1041e69ccf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayEvaluation.kt @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.torState + +import com.vitorpamplona.amethyst.ui.tor.TorType +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion + +class TorRelayEvaluation( + val torSettings: TorRelaySettings, + val trustedRelayList: Set, + val dmRelayList: Set, +) { + fun useTor(relay: NormalizedRelayUrl): Boolean = + if (torSettings.torType == TorType.OFF) { + false + } else { + if (relay.isLocalHost()) { + false + } else if (relay.isOnion()) { + torSettings.onionRelaysViaTor + } else if (relay in dmRelayList) { + torSettings.dmRelaysViaTor + } else if (relay in trustedRelayList) { + torSettings.trustedRelaysViaTor + } else { + torSettings.newRelaysViaTor + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelaySettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelaySettings.kt new file mode 100644 index 0000000000..e33477d052 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelaySettings.kt @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.torState + +import com.vitorpamplona.amethyst.ui.tor.TorType + +data class TorRelaySettings( + val torType: TorType = TorType.OFF, + val onionRelaysViaTor: Boolean = true, + val dmRelaysViaTor: Boolean = false, + val trustedRelaysViaTor: Boolean = false, + val newRelaysViaTor: Boolean = false, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt new file mode 100644 index 0000000000..f1190941eb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt @@ -0,0 +1,120 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.torState + +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState +import com.vitorpamplona.amethyst.model.serverList.TrustedRelayListsState +import com.vitorpamplona.amethyst.ui.tor.TorType +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.combineTransform +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +class TorRelayState( + val trustedRelayState: TrustedRelayListsState, + val dmRelayState: DmRelayListState, + val settings: AccountSettings, + val scope: CoroutineScope, +) { + val torSettings = + combine( + settings.torSettings.torType, + settings.torSettings.onionRelaysViaTor, + settings.torSettings.dmRelaysViaTor, + settings.torSettings.trustedRelaysViaTor, + settings.torSettings.newRelaysViaTor, + ) { + torType: TorType, + onionRelaysViaTor: Boolean, + dmRelaysViaTor: Boolean, + trustedRelaysViaTor: Boolean, + newRelaysViaTor: Boolean, + -> + TorRelaySettings( + torType = torType, + onionRelaysViaTor = onionRelaysViaTor, + dmRelaysViaTor = dmRelaysViaTor, + trustedRelaysViaTor = trustedRelaysViaTor, + newRelaysViaTor = newRelaysViaTor, + ) + }.onStart { + emit( + TorRelaySettings( + torType = settings.torSettings.torType.value, + onionRelaysViaTor = settings.torSettings.onionRelaysViaTor.value, + dmRelaysViaTor = settings.torSettings.dmRelaysViaTor.value, + trustedRelaysViaTor = settings.torSettings.trustedRelaysViaTor.value, + newRelaysViaTor = settings.torSettings.newRelaysViaTor.value, + ), + ) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + TorRelaySettings( + torType = settings.torSettings.torType.value, + onionRelaysViaTor = settings.torSettings.onionRelaysViaTor.value, + dmRelaysViaTor = settings.torSettings.dmRelaysViaTor.value, + trustedRelaysViaTor = settings.torSettings.trustedRelaysViaTor.value, + newRelaysViaTor = settings.torSettings.newRelaysViaTor.value, + ), + ) + + val flow = + combineTransform( + torSettings, + trustedRelayState.flow, + dmRelayState.flow, + ) { torSettings: TorRelaySettings, trustedRelayList: Set, dmRelayList: Set -> + emit( + TorRelayEvaluation( + torSettings = torSettings, + trustedRelayList = trustedRelayList, + dmRelayList = dmRelayList, + ), + ) + }.onStart { + emit( + TorRelayEvaluation( + torSettings = torSettings.value, + trustedRelayList = trustedRelayState.flow.value, + dmRelayList = dmRelayState.flow.value, + ), + ) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + TorRelayEvaluation( + torSettings = torSettings.value, + trustedRelayList = trustedRelayState.flow.value, + dmRelayList = dmRelayState.flow.value, + ), + ) + + fun shouldUseTorForClean(relay: NormalizedRelayUrl) = flow.value.useTor(relay) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ApplicationExt.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ApplicationExt.kt index 9fc034bd57..13f7e5e047 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ApplicationExt.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ApplicationExt.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ByteFormatter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ByteFormatter.kt index d3eec1de62..daea83b1d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ByteFormatter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ByteFormatter.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt index cc5b30f6b2..ca94c68426 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt @@ -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 @@ -63,3 +63,23 @@ object CachedRichTextParser { } } } + +object CachedUrlParser { + private val parsedUrlsCache = LruCache>(10) + + fun cachedParseValidUrls(content: String): List = parsedUrlsCache[content.hashCode()] + + fun parseValidUrls(content: String): List { + if (content.isEmpty()) return emptyList() + + val key = content.hashCode() + val cached = parsedUrlsCache[key] + return if (cached != null) { + cached + } else { + val newUrls = RichTextParser().parseValidUrls(content).toList() + parsedUrlsCache.put(key, newUrls) + newUrls + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt deleted file mode 100644 index ba2bc569f7..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt +++ /dev/null @@ -1,417 +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.service - -import android.content.Context -import android.util.LruCache -import androidx.compose.runtime.Immutable -import com.fasterxml.jackson.databind.node.JsonNodeFactory -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver -import com.vitorpamplona.amethyst.ui.components.GenericLoadable -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Serializable -import kotlinx.serialization.cbor.ByteString -import kotlinx.serialization.cbor.Cbor -import kotlinx.serialization.decodeFromByteArray -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import java.util.Base64 -import kotlin.coroutines.cancellation.CancellationException - -@Immutable -data class CashuToken( - val token: String, - val mint: String, - val totalAmount: Long, - val proofs: List, -) - -@Serializable -@Immutable -class Proof( - val amount: Int, - val id: String, - val secret: String, - val C: String, -) - -object CachedCashuProcessor { - val cashuCache = LruCache>>(20) - - fun cached(token: String): GenericLoadable> = cashuCache[token] ?: GenericLoadable.Loading() - - fun parse(token: String): GenericLoadable> { - if (cashuCache[token] !is GenericLoadable.Loaded) { - checkNotInMainThread() - val newCachuData = CashuProcessor().parse(token) - - cashuCache.put(token, newCachuData) - } - - return cashuCache[token] - } -} - -class CashuProcessor { - @Serializable - class V3Token( - val unit: String?, // unit - val memo: String?, // memo - val token: List?, - ) - - @Serializable - class V3T( - val mint: String, - val proofs: List, - ) - - fun parse(cashuToken: String): GenericLoadable> { - checkNotInMainThread() - - if (cashuToken.startsWith("cashuA")) { - return parseCashuA(cashuToken) - } - - if (cashuToken.startsWith("cashuB")) { - return parseCashuB(cashuToken) - } - - return GenericLoadable.Error("Could not parse this cashu token") - } - - fun parseCashuA(cashuToken: String): GenericLoadable> { - checkNotInMainThread() - - try { - val base64token = cashuToken.replace("cashuA", "") - val cashu = jacksonObjectMapper().readValue(String(Base64.getDecoder().decode(base64token))) - - if (cashu.token == null) { - return GenericLoadable.Error("No token found") - } - - val converted = - cashu.token.map { token -> - val proofs = token.proofs - val mint = token.mint - - var totalAmount = 0L - for (proof in proofs) { - totalAmount += proof.amount - } - - CashuToken(cashuToken, mint, totalAmount, proofs) - } - - return GenericLoadable.Loaded(converted.toImmutableList()) - } catch (e: Exception) { - if (e is CancellationException) throw e - return GenericLoadable.Error("Could not parse this cashu token") - } - } - - @Serializable - class V4Token( - val m: String, // mint - val u: String, // unit - val d: String? = null, // memo - val t: Array?, - ) - - @Serializable - class V4T( - @ByteString - val i: ByteArray, // identifier - val p: Array, - ) - - @Serializable - class V4Proof( - val a: Int, // amount - val s: String, // secret - @ByteString - val c: ByteArray, // signature - val d: V4DleqProof? = null, // no idea what this is - val w: String? = null, // witness - ) - - @Serializable - class V4DleqProof( - @ByteString - val e: ByteArray, - @ByteString - val s: ByteArray, - @ByteString - val r: ByteArray, - ) - - @OptIn(ExperimentalSerializationApi::class) - fun parseCashuB(cashuToken: String): GenericLoadable> { - checkNotInMainThread() - - try { - val base64token = cashuToken.replace("cashuB", "") - - val parser = Cbor { ignoreUnknownKeys = true } - - val v4Token = parser.decodeFromByteArray(Base64.getUrlDecoder().decode(base64token)) - - val v4proofs = v4Token.t ?: return GenericLoadable.Error("No token found") - - val converted = - v4proofs.map { id -> - val proofs = - id.p.map { - Proof( - it.a, - id.i.toHexKey(), - it.s, - it.c.toHexKey(), - ) - } - val mint = v4Token.m - - var totalAmount = 0L - for (proof in proofs) { - totalAmount += proof.amount - } - - CashuToken(cashuToken, mint, totalAmount, proofs) - } - - return GenericLoadable.Loaded(converted.toImmutableList()) - } catch (e: Exception) { - e.printStackTrace() - if (e is CancellationException) throw e - return GenericLoadable.Error("Could not parse this cashu token") - } - } - - suspend fun melt( - token: CashuToken, - lud16: String, - okHttpClient: (String) -> OkHttpClient, - onSuccess: (String, String) -> Unit, - onError: (String, String) -> Unit, - context: Context, - ) { - checkNotInMainThread() - - runCatching { - LightningAddressResolver() - .lnAddressInvoice( - lnaddress = lud16, - // Make invoice and leave room for fees - milliSats = token.totalAmount * 1000, - message = "Calculate Fees for Cashu", - okHttpClient = okHttpClient, - onSuccess = { baseInvoice -> - feeCalculator( - token.mint, - baseInvoice, - okHttpClient = okHttpClient, - onSuccess = { fees -> - LightningAddressResolver() - .lnAddressInvoice( - lnaddress = lud16, - // Make invoice and leave room for fees - milliSats = (token.totalAmount - fees) * 1000, - message = "Redeem Cashu", - okHttpClient = okHttpClient, - onSuccess = { invoice -> - meltInvoice(token, invoice, fees, okHttpClient, onSuccess, onError, context) - }, - onProgress = {}, - onError = onError, - context = context, - ) - }, - onError = onError, - context, - ) - }, - onProgress = {}, - onError = onError, - context = context, - ) - } - } - - fun feeCalculator( - mintAddress: String, - invoice: String, - okHttpClient: (String) -> OkHttpClient, - onSuccess: (Int) -> Unit, - onError: (String, String) -> Unit, - context: Context, - ) { - checkNotInMainThread() - - try { - val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint - val client = okHttpClient(url) - - val factory = JsonNodeFactory.instance - - val jsonObject = factory.objectNode() - jsonObject.put("pr", invoice) - - val mediaType = "application/json; charset=utf-8".toMediaType() - val requestBody = jsonObject.toString().toRequestBody(mediaType) - val request = - Request - .Builder() - .url(url) - .post(requestBody) - .build() - - client.newCall(request).execute().use { - val body = it.body.string() - val tree = jacksonObjectMapper().readTree(body) - - val feeCost = tree?.get("fee")?.asInt() - - if (feeCost != null) { - onSuccess( - feeCost, - ) - } else { - val msg = - tree - ?.get("detail") - ?.asText() - ?.split('.') - ?.getOrNull(0) - ?.ifBlank { null } - onError( - stringRes(context, R.string.cashu_failed_redemption), - if (msg != null) { - stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, msg) - } else { - stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg) - }, - ) - } - } - } catch (e: Exception) { - if (e is CancellationException) throw e - onError( - stringRes(context, R.string.cashu_successful_redemption), - stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message), - ) - } - } - - private fun meltInvoice( - token: CashuToken, - invoice: String, - fees: Int, - okHttpClient: (String) -> OkHttpClient, - onSuccess: (String, String) -> Unit, - onError: (String, String) -> Unit, - context: Context, - ) { - try { - val url = token.mint + "/melt" // Melt cashu tokens at Mint - val client = okHttpClient(url) - - val factory = JsonNodeFactory.instance - - val jsonObject = factory.objectNode() - - jsonObject.replace( - "proofs", - factory.arrayNode(token.proofs.size).apply { - token.proofs.forEach { - addObject().apply { - put("amount", it.amount) - put("id", it.id) - put("secret", it.secret) - put("C", it.C) - } - } - }, - ) - - jsonObject.put("pr", invoice) - - val mediaType = "application/json; charset=utf-8".toMediaType() - val requestBody = jsonObject.toString().toRequestBody(mediaType) - val request = - Request - .Builder() - .url(url) - .post(requestBody) - .build() - - client.newCall(request).execute().use { - val body = it.body.string() - val tree = jacksonObjectMapper().readTree(body) - - val successful = tree?.get("paid")?.asText() == "true" - - if (successful) { - onSuccess( - stringRes(context, R.string.cashu_successful_redemption), - stringRes( - context, - R.string.cashu_successful_redemption_explainer, - token.totalAmount.toString(), - fees.toString(), - ), - ) - } else { - val msg = - tree - ?.get("detail") - ?.asText() - ?.split('.') - ?.getOrNull(0) - ?.ifBlank { null } - onError( - stringRes(context, R.string.cashu_failed_redemption), - if (msg != null) { - stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, msg) - } else { - stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg) - }, - ) - } - } - } catch (e: Exception) { - if (e is CancellationException) throw e - onError( - stringRes(context, R.string.cashu_successful_redemption), - stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message), - ) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CoroutinesExt.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CoroutinesExt.kt index e19df5073c..7475b91cee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CoroutinesExt.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CoroutinesExt.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CountFormatter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CountFormatter.kt index 469173b694..84346cac45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CountFormatter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CountFormatter.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/EmojiUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/EmojiUtils.kt index 36433e85aa..2f78457f33 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/EmojiUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/EmojiUtils.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/HttpStatusMessages.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/HttpStatusMessages.kt index 009038d548..969f08676e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/HttpStatusMessages.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/HttpStatusMessages.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/IterableExt.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/IterableExt.kt index 773b9c4b20..2dad80ec59 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/IterableExt.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/IterableExt.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/MainThreadChecker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/MainThreadChecker.kt index a985b04de2..7eb066f72f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/MainThreadChecker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/MainThreadChecker.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt index 98609bdd5e..35fb7b90dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt @@ -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,18 +20,18 @@ */ package com.vitorpamplona.amethyst.service -import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05 import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.coroutines.executeAsync class Nip05NostrAddressVerifier { suspend fun fetchNip05Json( nip05: String, - okttpClient: (String) -> OkHttpClient, + okHttpClient: (String) -> OkHttpClient, onSuccess: suspend (String) -> Unit, onError: (String) -> Unit, ) = withContext(Dispatchers.IO) { @@ -45,30 +45,22 @@ class Nip05NostrAddressVerifier { } try { - val request = - Request - .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") - .url(url) - .build() - // Fetchers MUST ignore any HTTP redirects given by the /.well-known/nostr.json endpoint. - okttpClient(url) - .newBuilder() - .followRedirects(false) - .build() - .newCall(request) - .execute() - .use { - checkNotInMainThread() + val request = Request.Builder().url(url).build() - if (it.isSuccessful) { - onSuccess(it.body.string()) + // Fetchers MUST ignore any HTTP redirects given by the /.well-known/nostr.json endpoint. + val client = okHttpClient(url).newBuilder().followRedirects(false).build() + + client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + if (response.isSuccessful) { + onSuccess(response.body.string()) } else { onError( - "Could not resolve $nip05. Error: ${it.code}. Check if the server is up and if the address $nip05 is correct", + "Could not resolve $nip05. Error: ${response.code}. Check if the server is up and if the address $nip05 is correct", ) } } + } } catch (e: Exception) { if (e is CancellationException) throw e onError("Could not resolve NIP-05 $nip05 as URL $url: ${e.message}") @@ -77,7 +69,7 @@ class Nip05NostrAddressVerifier { suspend fun verifyNip05( nip05: String, - okttpClient: (String) -> OkHttpClient, + okHttpClient: (String) -> OkHttpClient, onSuccess: suspend (String) -> Unit, onError: (String) -> Unit, ) { @@ -86,7 +78,7 @@ class Nip05NostrAddressVerifier { fetchNip05Json( nip05, - okttpClient, + okHttpClient, onSuccess = { checkNotInMainThread() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt index ec9567f498..d58d29dd35 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt @@ -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,97 +20,126 @@ */ package com.vitorpamplona.amethyst.service -import android.content.ContentProviderOperation.newCall import android.util.Log import android.util.LruCache +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CancellationException -import okhttp3.Call -import okhttp3.Callback +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.Response -import java.io.IOException +import okhttp3.coroutines.executeAsync object Nip11CachedRetriever { - open class RetrieveResult( - val time: Long, - ) - - class RetrieveResultError( - val error: Nip11Retriever.ErrorCode, - val msg: String? = null, - ) : RetrieveResult(TimeUtils.now()) - - class RetrieveResultSuccess( + sealed class RetrieveResult( val data: Nip11RelayInformation, - ) : RetrieveResult(TimeUtils.now()) + val time: Long, + ) { + class Error( + data: Nip11RelayInformation, + val error: Nip11Retriever.ErrorCode, + val msg: String? = null, + ) : RetrieveResult(data, TimeUtils.now()) - class RetrieveResultLoading : RetrieveResult(TimeUtils.now()) + class Success( + data: Nip11RelayInformation, + ) : RetrieveResult(data, TimeUtils.now()) - private val relayInformationDocumentCache = LruCache(100) + class Loading( + data: Nip11RelayInformation, + ) : RetrieveResult(data, TimeUtils.now()) + + class Empty( + data: Nip11RelayInformation, + ) : RetrieveResult(data, TimeUtils.now()) + } + + private val relayInformationEmptyCache = LruCache(1000) + private val relayInformationDocumentCache = LruCache(1000) private val retriever = Nip11Retriever() - fun getFromCache(dirtyUrl: String): Nip11RelayInformation? { - val result = relayInformationDocumentCache.get(RelayUrlFormatter.getHttpsUrl(dirtyUrl)) ?: return null - if (result is RetrieveResultSuccess) return result.data - return null + fun getEmpty(relay: NormalizedRelayUrl): Nip11RelayInformation { + relayInformationEmptyCache.get(relay)?.let { return it } + + val info = + Nip11RelayInformation( + name = relay.displayUrl(), + icon = relay.toHttp() + "favicon.ico", + ) + + relayInformationEmptyCache.put(relay, info) + + return info + } + + fun getFromCache(relay: NormalizedRelayUrl): Nip11RelayInformation { + val result = relayInformationDocumentCache.get(relay) + + return when (result) { + is RetrieveResult.Success -> return result.data + is RetrieveResult.Error -> return result.data + is RetrieveResult.Empty -> return result.data + is RetrieveResult.Loading -> return result.data + else -> { + val empty = getEmpty(relay) + relayInformationDocumentCache.put(relay, RetrieveResult.Empty(empty)) + empty + } + } } suspend fun loadRelayInfo( - dirtyUrl: String, + relay: NormalizedRelayUrl, okHttpClient: (String) -> OkHttpClient, onInfo: (Nip11RelayInformation) -> Unit, - onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit, + onError: (NormalizedRelayUrl, Nip11Retriever.ErrorCode, String?) -> Unit, ) { - checkNotInMainThread() - val url = RelayUrlFormatter.getHttpsUrl(dirtyUrl) - val doc = relayInformationDocumentCache.get(url) - + val doc = relayInformationDocumentCache.get(relay) if (doc != null) { - if (doc is RetrieveResultSuccess) { + if (doc is RetrieveResult.Success) { onInfo(doc.data) - } else if (doc is RetrieveResultLoading) { + } else if (doc is RetrieveResult.Loading) { if (TimeUtils.now() - doc.time < TimeUtils.ONE_MINUTE) { // just wait. } else { - retrieve(url, dirtyUrl, okHttpClient, onInfo, onError) + retrieve(relay, okHttpClient, onInfo, onError) } - } else if (doc is RetrieveResultError) { + } else if (doc is RetrieveResult.Error) { if (TimeUtils.now() - doc.time < TimeUtils.ONE_HOUR) { - onError(dirtyUrl, doc.error, null) + onError(relay, doc.error, null) } else { - retrieve(url, dirtyUrl, okHttpClient, onInfo, onError) + retrieve(relay, okHttpClient, onInfo, onError) } + } else { + // Empty + retrieve(relay, okHttpClient, onInfo, onError) } } else { - retrieve(url, dirtyUrl, okHttpClient, onInfo, onError) + retrieve(relay, okHttpClient, onInfo, onError) } } private suspend fun retrieve( - url: String, - dirtyUrl: String, + relay: NormalizedRelayUrl, okHttpClient: (String) -> OkHttpClient, onInfo: (Nip11RelayInformation) -> Unit, - onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit, + onError: (NormalizedRelayUrl, Nip11Retriever.ErrorCode, String?) -> Unit, ) { - relayInformationDocumentCache.put(url, RetrieveResultLoading()) + relayInformationDocumentCache.put(relay, RetrieveResult.Loading(getEmpty(relay))) retriever.loadRelayInfo( - url = url, - dirtyUrl = dirtyUrl, + relay = relay, okHttpClient = okHttpClient, onInfo = { - checkNotInMainThread() - relayInformationDocumentCache.put(url, RetrieveResultSuccess(it)) + relayInformationDocumentCache.put(relay, RetrieveResult.Success(it)) onInfo(it) }, - onError = { dirtyUrl, code, errorMsg -> - checkNotInMainThread() - relayInformationDocumentCache.put(url, RetrieveResultError(code, errorMsg)) - onError(url, code, errorMsg) + onError = { relay, code, errorMsg -> + relayInformationDocumentCache.put(relay, RetrieveResult.Error(getEmpty(relay), code, errorMsg)) + onError(relay, code, errorMsg) }, ) } @@ -125,13 +154,12 @@ class Nip11Retriever { } suspend fun loadRelayInfo( - url: String, - dirtyUrl: String, + relay: NormalizedRelayUrl, okHttpClient: (String) -> OkHttpClient, onInfo: (Nip11RelayInformation) -> Unit, - onError: (String, ErrorCode, String?) -> Unit, + onError: (NormalizedRelayUrl, ErrorCode, String?) -> Unit, ) { - checkNotInMainThread() + val url = relay.toHttp() try { val request: Request = Request @@ -140,48 +168,32 @@ class Nip11Retriever { .url(url) .build() - okHttpClient(url) - .newCall(request) - .enqueue( - object : Callback { - override fun onResponse( - call: Call, - response: Response, - ) { - checkNotInMainThread() - response.use { - val body = it.body.string() - try { - if (it.isSuccessful) { - onInfo(Nip11RelayInformation.fromJson(body)) - } else { - onError(dirtyUrl, ErrorCode.FAIL_WITH_HTTP_STATUS, it.code.toString()) - } - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e( - "RelayInfoFail", - "Resulting Message from Relay $dirtyUrl in not parseable: $body", - e, - ) - onError(dirtyUrl, ErrorCode.FAIL_TO_PARSE_RESULT, e.message) - } - } - } + val client = okHttpClient(url) - override fun onFailure( - call: Call, - e: IOException, - ) { - Log.e("RelayInfoFail", "$dirtyUrl unavailable", e) - onError(dirtyUrl, ErrorCode.FAIL_TO_REACH_SERVER, e.message) + client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + val body = response.body.string() + try { + if (response.isSuccessful) { + onInfo(Nip11RelayInformation.fromJson(body)) + } else { + onError(relay, ErrorCode.FAIL_WITH_HTTP_STATUS, response.code.toString()) } - }, - ) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e( + "RelayInfoFail", + "Resulting Message from Relay ${relay.url} in not parseable: $body", + e, + ) + onError(relay, ErrorCode.FAIL_TO_PARSE_RESULT, e.message) + } + } + } } catch (e: Exception) { if (e is CancellationException) throw e - Log.e("RelayInfoFail", "Invalid URL $dirtyUrl", e) - onError(dirtyUrl, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message) + Log.e("RelayInfoFail", "Invalid URL ${relay.url}", e) + onError(relay, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt deleted file mode 100644 index 46179fa347..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ /dev/null @@ -1,514 +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.service - -import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relays.EOSEAccount -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES -import com.vitorpamplona.ammolite.relays.Relay -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.blossom.BlossomServersEvent -import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent -import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent -import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent -import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent -import com.vitorpamplona.quartz.nip18Reposts.RepostEvent -import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent -import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent -import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent -import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent -import com.vitorpamplona.quartz.nip37Drafts.DraftEvent -import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent -import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent -import com.vitorpamplona.quartz.nip52Calendar.CalendarDateSlotEvent -import com.vitorpamplona.quartz.nip52Calendar.CalendarRSVPEvent -import com.vitorpamplona.quartz.nip52Calendar.CalendarTimeSlotEvent -import com.vitorpamplona.quartz.nip56Reports.ReportEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent -import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent -import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent -import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent -import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent -import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent -import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent -import com.vitorpamplona.quartz.utils.TimeUtils - -// TODO: Migrate this to a property of AccountVi -object NostrAccountDataSource : AmethystNostrDataSource("AccountData") { - lateinit var account: Account - var otherAccounts = listOf() - - val latestEOSEs = EOSEAccount() - val hasLoadedTheBasics = mutableMapOf() - - fun createAccountMetadataFilter(): TypedFilter = - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - MetadataEvent.KIND, - ContactListEvent.KIND, - StatusEvent.KIND, - AdvertisedRelayListEvent.KIND, - ChatMessageRelayListEvent.KIND, - SearchRelayListEvent.KIND, - FileServersEvent.KIND, - BlossomServersEvent.KIND, - PrivateOutboxRelayListEvent.KIND, - ), - authors = listOf(account.userProfile().pubkeyHex), - limit = 20, - ), - ) - - fun createOtherAccountsBaseFilter(): TypedFilter? { - val otherAuthors = otherAccounts.filter { it != account.userProfile().pubkeyHex } - if (otherAuthors.isEmpty()) return null - return TypedFilter( - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - MetadataEvent.KIND, - ContactListEvent.KIND, - AdvertisedRelayListEvent.KIND, - ChatMessageRelayListEvent.KIND, - SearchRelayListEvent.KIND, - FileServersEvent.KIND, - BlossomServersEvent.KIND, - MuteListEvent.KIND, - PeopleListEvent.KIND, - ), - authors = otherAuthors, - limit = otherAuthors.size * 20, - ), - ) - } - - fun createAccountSettingsFilter(): TypedFilter = - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, MuteListEvent.KIND, BadgeProfilesEvent.KIND, EmojiPackSelectionEvent.KIND), - authors = listOf(account.userProfile().pubkeyHex), - limit = 100, - ), - ) - - fun createAccountSettings2Filter(): TypedFilter = - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(AppSpecificDataEvent.KIND), - authors = listOf(account.userProfile().pubkeyHex), - tags = mapOf("d" to listOf(Account.APP_SPECIFIC_DATA_D_TAG)), - limit = 1, - ), - ) - - fun createAccountReportsFilter(): TypedFilter = - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(DraftEvent.KIND, ReportEvent.KIND), - authors = listOf(account.userProfile().pubkeyHex), - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultNotificationFollowList.value) - ?.relayList, - ), - ) - - fun createAccountLastPostsListFilter(): TypedFilter = - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - authors = listOf(account.userProfile().pubkeyHex), - limit = 400, - ), - ) - - fun createNotificationFilter(): TypedFilter { - var since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultNotificationFollowList.value) - ?.relayList - ?.toMutableMap() - - if (since == null) { - since = - account.connectToRelays.value - .associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) } - .toMutableMap() - } else { - account.connectToRelays.value.forEach { - val eose = since.get(it.url) - if (eose == null) { - since.put(it.url, EOSETime(TimeUtils.oneWeekAgo())) - } - } - } - - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - TextNoteEvent.KIND, - PollNoteEvent.KIND, - ReactionEvent.KIND, - RepostEvent.KIND, - GenericRepostEvent.KIND, - ReportEvent.KIND, - LnZapEvent.KIND, - LnZapPaymentResponseEvent.KIND, - ChannelMessageEvent.KIND, - BadgeAwardEvent.KIND, - ), - tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), - limit = 4000, - since = since, - ), - ) - } - - fun createNotificationFilter2(): TypedFilter { - val since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultNotificationFollowList.value) - ?.relayList - ?: account.connectToRelays.value.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) } - ?: account.convertLocalRelays().associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) } - - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - GitReplyEvent.KIND, - GitIssueEvent.KIND, - GitPatchEvent.KIND, - HighlightEvent.KIND, - CommentEvent.KIND, - CalendarDateSlotEvent.KIND, - CalendarTimeSlotEvent.KIND, - CalendarRSVPEvent.KIND, - InteractiveStoryPrologueEvent.KIND, - InteractiveStorySceneEvent.KIND, - ), - tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), - limit = 400, - since = since, - ), - ) - } - - fun createGiftWrapsToMeFilter() = - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(GiftWrapEvent.KIND), - tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get("&&((GIFTWRAPS_EOSE))&&") - ?.relayList - ?.mapValues { - EOSETime(it.value.time - TimeUtils.twoDays()) - }, - ), - ) - - val accountChannel = - requestNewChannel { time, relayUrl -> - if (hasLoadedTheBasics[account.userProfile()] != null) { - latestEOSEs.addOrUpdate( - account.userProfile(), - account.settings.defaultNotificationFollowList.value, - relayUrl, - time, - ) - - latestEOSEs.addOrUpdate( - account.userProfile(), - "&&((GIFTWRAPS_EOSE))&&", - relayUrl, - time, - ) - } else { - hasLoadedTheBasics[account.userProfile()] = true - - invalidateFilters() - } - } - - override fun consume( - event: Event, - relay: Relay, - ) { - if (LocalCache.justVerify(event)) { - consumeAlreadyVerified(event, relay) - } - } - - fun consumeAlreadyVerified( - event: Event, - relay: Relay, - ) { - checkNotInMainThread() - - when (event) { - is OtsEvent -> { - // verifies new OTS upon arrival - Amethyst.instance.otsVerifCache.cacheVerify(event, account::otsResolver) - } - - is PrivateOutboxRelayListEvent -> { - val note = LocalCache.getAddressableNoteIfExists(event.addressTag()) - val noteEvent = note?.event - if (noteEvent == null || event.createdAt > noteEvent.createdAt) { - event.privateTags(account.signer) { - LocalCache.justConsume(event, relay) - } - } - } - - is DraftEvent -> { - // Avoid decrypting over and over again if the event already exist. - - if (!event.isDeleted()) { - val note = LocalCache.getAddressableNoteIfExists(event.addressTag()) - val noteEvent = note?.event - if (noteEvent != null) { - if (event.createdAt > noteEvent.createdAt || relay.brief !in note.relays) { - LocalCache.consume(event, relay) - } - } else { - // decrypts - event.cachedDraft(account.signer) {} - - LocalCache.justConsume(event, relay) - } - } - } - - is GiftWrapEvent -> { - // Avoid decrypting over and over again if the event already exist. - val note = LocalCache.getNoteIfExists(event.id) - val noteEvent = note?.event as? GiftWrapEvent - if (noteEvent != null) { - if (relay.brief !in note.relays) { - LocalCache.justConsume(noteEvent, relay) - - noteEvent.innerEventId?.let { - (LocalCache.getNoteIfExists(it)?.event as? Event)?.let { - this.consumeAlreadyVerified(it, relay) - } - } ?: run { - event.unwrap(account.signer) { - this.consume(it, relay) - noteEvent.innerEventId = it.id - } - } - } - } else { - // new event - event.unwrap(account.signer) { - LocalCache.justConsume(event, relay) - this.consume(it, relay) - } - } - } - - is SealedRumorEvent -> { - // Avoid decrypting over and over again if the event already exist. - val note = LocalCache.getNoteIfExists(event.id) - val noteEvent = note?.event as? SealedRumorEvent - if (noteEvent != null) { - if (relay.brief !in note.relays) { - LocalCache.justConsume(noteEvent, relay) - - noteEvent.innerEventId?.let { - (LocalCache.getNoteIfExists(it)?.event as? Event)?.let { - LocalCache.justConsume(it, relay) - } - } ?: run { - event.unseal(account.signer) { - LocalCache.justConsume(it, relay) - noteEvent.innerEventId = it.id - } - } - } - } else { - // new event - event.unseal(account.signer) { - LocalCache.justConsume(event, relay) - LocalCache.justConsume(it, relay) - } - } - } - - is LnZapEvent -> { - // Avoid decrypting over and over again if the event already exist. - val note = LocalCache.getNoteIfExists(event.id) - if (note?.event == null) { - event.zapRequest?.let { - if (it.isPrivateZap()) { - it.decryptPrivateZap(account.signer) {} - } - } - - LocalCache.justConsume(event, relay) - } - } - - else -> { - LocalCache.justConsume(event, relay) - } - } - } - - override fun markAsSeenOnRelay( - eventId: String, - relay: Relay, - ) { - checkNotInMainThread() - - super.markAsSeenOnRelay(eventId, relay) - - val note = LocalCache.getNoteIfExists(eventId) ?: return - val noteEvent = note.event ?: return - markInnerAsSeenOnRelay(noteEvent, relay) - } - - private fun markInnerAsSeenOnRelay( - newNoteEvent: Event, - relay: Relay, - ) { - markInnerAsSeenOnRelay(newNoteEvent.id, relay) - } - - private fun markInnerAsSeenOnRelay( - eventId: HexKey, - relay: Relay, - ) { - val note = LocalCache.getNoteIfExists(eventId) - - if (note != null) { - note.addRelay(relay) - - val noteEvent = note.event - if (noteEvent is GiftWrapEvent) { - noteEvent.innerEventId?.let { - markInnerAsSeenOnRelay(it, relay) - } - } else if (noteEvent is SealedRumorEvent) { - noteEvent.innerEventId?.let { - markInnerAsSeenOnRelay(it, relay) - } - } - } - } - - override fun updateChannelFilters() = - if (hasLoadedTheBasics[account.userProfile()] != null) { - // gets everything about the user logged in - accountChannel.typedFilters = - listOfNotNull( - createAccountMetadataFilter(), - createAccountSettings2Filter(), - createNotificationFilter(), - createNotificationFilter2(), - createGiftWrapsToMeFilter(), - createAccountReportsFilter(), - createAccountSettingsFilter(), - createAccountLastPostsListFilter(), - createOtherAccountsBaseFilter(), - ).ifEmpty { null } - } else { - // just the basics. - accountChannel.typedFilters = - listOf( - createAccountMetadataFilter(), - createAccountSettingsFilter(), - createAccountSettings2Filter(), - ).ifEmpty { null } - } - - override fun auth( - relay: Relay, - challenge: String, - ) { - super.auth(relay, challenge) - - if (this::account.isInitialized) { - account.sendAuthEvent(relay, challenge) - } - } - - override fun notify( - relay: Relay, - description: String, - ) { - super.notify(relay, description) - - if (this::account.isInitialized) { - account.addPaymentRequestIfNew(Account.PaymentRequest(relay.url, description)) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt deleted file mode 100644 index 310b9d14f5..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt +++ /dev/null @@ -1,118 +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.service - -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.Channel -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel -import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent -import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent - -object NostrChannelDataSource : AmethystNostrDataSource("ChatroomFeed") { - var account: Account? = null - var channel: Channel? = null - - fun loadMessagesBetween( - account: Account, - channel: Channel, - ) { - this.account = account - this.channel = channel - resetFilters() - } - - fun clear() { - account = null - channel = null - } - - fun createMessagesByMeToChannelFilter(): TypedFilter? { - val myAccount = account ?: return null - - if (channel is PublicChatChannel) { - // Brings on messages by the user from all other relays. - // Since we ship with write to public, read from private only - // this guarantees that messages from the author do not disappear. - return TypedFilter( - types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH), - filter = - SincePerRelayFilter( - kinds = listOf(ChannelMessageEvent.KIND), - authors = listOf(myAccount.userProfile().pubkeyHex), - limit = 50, - ), - ) - } else if (channel is LiveActivitiesChannel) { - // Brings on messages by the user from all other relays. - // Since we ship with write to public, read from private only - // this guarantees that messages from the author do not disappear. - return TypedFilter( - types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH), - filter = - SincePerRelayFilter( - kinds = listOf(LiveActivitiesChatMessageEvent.KIND), - authors = listOf(myAccount.userProfile().pubkeyHex), - limit = 50, - ), - ) - } - return null - } - - fun createMessagesToChannelFilter(): TypedFilter? { - if (channel is PublicChatChannel) { - return TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = - SincePerRelayFilter( - kinds = listOf(ChannelMessageEvent.KIND), - tags = mapOf("e" to listOfNotNull(channel?.idHex)), - limit = 200, - ), - ) - } else if (channel is LiveActivitiesChannel) { - return TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = - SincePerRelayFilter( - kinds = listOf(LiveActivitiesChatMessageEvent.KIND), - tags = mapOf("a" to listOfNotNull(channel?.idHex)), - limit = 200, - ), - ) - } - return null - } - - val messagesChannel = requestNewChannel() - - override fun updateChannelFilters() { - messagesChannel.typedFilters = - listOfNotNull( - createMessagesToChannelFilter(), - createMessagesByMeToChannelFilter(), - ).ifEmpty { null } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt deleted file mode 100644 index 130c17a379..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt +++ /dev/null @@ -1,108 +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.service - -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.relays.EOSEAccount -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent -import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey - -object NostrChatroomDataSource : AmethystNostrDataSource("ChatroomFeed") { - lateinit var account: Account - private var withRoom: ChatroomKey? = null - - private val latestEOSEs = EOSEAccount() - - fun loadMessagesBetween( - accountIn: Account, - withRoom: ChatroomKey, - ) { - this.account = accountIn - this.withRoom = withRoom - resetFilters() - } - - fun createMessagesToMeFilter(): TypedFilter? { - val myPeer = withRoom - - return if (myPeer != null) { - TypedFilter( - types = setOf(FeedType.PRIVATE_DMS), - filter = - SincePerRelayFilter( - kinds = listOf(PrivateDmEvent.KIND), - authors = myPeer.users.toList(), - tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(withRoom.hashCode().toString()) - ?.relayList, - ), - ) - } else { - null - } - } - - fun createMessagesFromMeFilter(): TypedFilter? { - val myPeer = withRoom - - return if (myPeer != null) { - TypedFilter( - types = setOf(FeedType.PRIVATE_DMS), - filter = - SincePerRelayFilter( - kinds = listOf(PrivateDmEvent.KIND), - authors = listOf(account.userProfile().pubkeyHex), - tags = mapOf("p" to myPeer.users.map { it }), - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(withRoom.hashCode().toString()) - ?.relayList, - ), - ) - } else { - null - } - } - - fun clearEOSEs(account: Account) { - latestEOSEs.removeDataFor(account.userProfile()) - } - - val inandoutChannel = - requestNewChannel { time, relayUrl -> - latestEOSEs.addOrUpdate(account.userProfile(), withRoom.hashCode().toString(), relayUrl, time) - } - - override fun updateChannelFilters() { - inandoutChannel.typedFilters = - listOfNotNull( - createMessagesToMeFilter(), - createMessagesFromMeFilter(), - ).ifEmpty { null } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt deleted file mode 100644 index 37fc97c84b..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt +++ /dev/null @@ -1,170 +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.service - -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.relays.EOSEAccount -import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent -import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent -import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent -import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent - -object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") { - lateinit var account: Account - - val latestEOSEs = EOSEAccount() - val chatRoomList = "ChatroomList" - - fun createMessagesToMeFilter() = - TypedFilter( - types = setOf(FeedType.PRIVATE_DMS), - filter = - SincePerRelayFilter( - kinds = listOf(PrivateDmEvent.KIND), - tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(chatRoomList) - ?.relayList, - ), - ) - - fun createMessagesFromMeFilter() = - TypedFilter( - types = setOf(FeedType.PRIVATE_DMS), - filter = - SincePerRelayFilter( - kinds = listOf(PrivateDmEvent.KIND), - authors = listOf(account.userProfile().pubkeyHex), - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(chatRoomList) - ?.relayList, - ), - ) - - fun createChannelsCreatedbyMeFilter() = - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = - SincePerRelayFilter( - kinds = listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND), - authors = listOf(account.userProfile().pubkeyHex), - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(chatRoomList) - ?.relayList, - ), - ) - - fun createMyChannelsFilter(): TypedFilter? { - val followingEvents = account.selectedChatsFollowList() - - if (followingEvents.isEmpty()) return null - - return TypedFilter( - // Metadata comes from any relay - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(ChannelCreateEvent.KIND), - ids = followingEvents.toList(), - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(chatRoomList) - ?.relayList, - ), - ) - } - - fun createLastChannelInfoFilter(): List? { - val followingEvents = account.selectedChatsFollowList() - - if (followingEvents.isEmpty()) return null - - return followingEvents.map { - TypedFilter( - // Metadata comes from any relay - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(ChannelMetadataEvent.KIND), - tags = mapOf("e" to listOf(it)), - limit = 1, - ), - ) - } - } - - fun createLastMessageOfEachChannelFilter(): List? { - val followingEvents = account.selectedChatsFollowList() - - if (followingEvents.isEmpty()) return null - - return followingEvents.map { - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = - SincePerRelayFilter( - kinds = listOf(ChannelMessageEvent.KIND), - tags = mapOf("e" to listOf(it)), - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(chatRoomList) - ?.relayList, - // Remember to consider spam that is being removed from the UI - limit = 50, - ), - ) - } - } - - val chatroomListChannel = - requestNewChannel { time, relayUrl -> - latestEOSEs.addOrUpdate(account.userProfile(), chatRoomList, relayUrl, time) - } - - override fun updateChannelFilters() { - val list = - listOfNotNull( - createMessagesToMeFilter(), - createMessagesFromMeFilter(), - createMyChannelsFilter(), - ) - - chatroomListChannel.typedFilters = - listOfNotNull( - list, - createLastChannelInfoFilter(), - createLastMessageOfEachChannelFilter(), - ).flatten() - .ifEmpty { null } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrCommunityDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrCommunityDataSource.kt deleted file mode 100644 index 70e74ee5bd..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrCommunityDataSource.kt +++ /dev/null @@ -1,71 +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.service - -import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent -import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent - -object NostrCommunityDataSource : AmethystNostrDataSource("SingleCommunityFeed") { - private var communityToWatch: AddressableNote? = null - - private fun createLoadCommunityFilter(): TypedFilter? { - val myCommunityToWatch = communityToWatch ?: return null - - val community = myCommunityToWatch.event as? CommunityDefinitionEvent ?: return null - - val authors = - community - .moderators() - .map { it.pubKey } - .plus(listOfNotNull(myCommunityToWatch.author?.pubkeyHex)) - - if (authors.isEmpty()) return null - - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - authors = authors, - tags = - mapOf( - "a" to listOf(myCommunityToWatch.address.toValue()), - ), - kinds = listOf(CommunityPostApprovalEvent.KIND), - limit = 500, - ), - ) - } - - val loadCommunityChannel = requestNewChannel() - - override fun updateChannelFilters() { - loadCommunityChannel.typedFilters = listOfNotNull(createLoadCommunityFilter()).ifEmpty { null } - } - - fun loadCommunity(note: AddressableNote?) { - communityToWatch = note - invalidateFilters() - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt deleted file mode 100644 index 6fbad76eca..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt +++ /dev/null @@ -1,455 +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.service - -import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.relays.EOSEAccount -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -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.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent -import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent -import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent -import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent -import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent -import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch - -object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") { - lateinit var account: Account - - val scope = Amethyst.instance.applicationIOScope - val latestEOSEs = EOSEAccount() - - var job: Job? = null - - override fun start() { - job?.cancel() - job = - scope.launch(Dispatchers.IO) { - account.liveDiscoveryFollowLists.collect { - if (this@NostrDiscoveryDataSource::account.isInitialized) { - invalidateFilters() - } - } - } - super.start() - } - - override fun stop() { - super.stop() - job?.cancel() - } - - fun createMarketplaceFilter(): List { - val follows = account.liveDiscoveryListAuthorsPerRelay.value?.ifEmpty { null } - val hashToLoad = - account.liveDiscoveryFollowLists.value - ?.hashtags - ?.toList() - ?.ifEmpty { null } - val geohashToLoad = - account.liveDiscoveryFollowLists.value - ?.geotags - ?.toList() - ?.ifEmpty { null } - - return listOfNotNull( - TypedFilter( - types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS), - filter = - SinceAuthorPerRelayFilter( - authors = follows, - kinds = listOf(ClassifiedsEvent.KIND), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ), - hashToLoad?.let { - TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - SincePerRelayFilter( - kinds = listOf(ClassifiedsEvent.KIND), - tags = - mapOf( - "t" to - it - .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } - .flatten(), - ), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ) - }, - geohashToLoad?.let { - TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - SincePerRelayFilter( - kinds = listOf(ClassifiedsEvent.KIND), - tags = - mapOf( - "g" to it, - ), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ) - }, - ) - } - - fun createNIP89Filter(kTags: List): List = - listOfNotNull( - TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - SincePerRelayFilter( - kinds = listOf(AppDefinitionEvent.KIND), - limit = 300, - tags = mapOf("k" to kTags), - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ), - ) - - fun createLiveStreamFilter(): List { - val follows = - account.liveDiscoveryFollowLists.value - ?.authors - ?.toList() - ?.ifEmpty { null } - - val followsRelays = account.liveDiscoveryListAuthorsPerRelay.value - - return listOfNotNull( - TypedFilter( - types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS), - filter = - SinceAuthorPerRelayFilter( - authors = followsRelays, - kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ), - follows?.let { - TypedFilter( - types = setOf(FeedType.FOLLOWS), - filter = - SincePerRelayFilter( - tags = mapOf("p" to it), - kinds = listOf(LiveActivitiesEvent.KIND), - limit = 100, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ) - }, - ) - } - - fun createPublicChatFilter(): List { - val follows = account.liveDiscoveryListAuthorsPerRelay.value?.ifEmpty { null } - val followChats = account.selectedChatsFollowList().toList() - - return listOfNotNull( - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = - SinceAuthorPerRelayFilter( - authors = follows, - kinds = listOf(ChannelMessageEvent.KIND), - limit = 500, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ), - if (followChats.isNotEmpty()) { - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = - SincePerRelayFilter( - ids = followChats, - kinds = listOf(ChannelCreateEvent.KIND, ChannelMessageEvent.KIND), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ) - } else { - null - }, - ) - } - - fun createCommunitiesFilter(): TypedFilter { - val follows = account.liveDiscoveryListAuthorsPerRelay.value - - return TypedFilter( - types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS), - filter = - SinceAuthorPerRelayFilter( - authors = follows, - kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ) - } - - fun createLiveStreamTagsFilter(): TypedFilter? { - val hashToLoad = - account.liveDiscoveryFollowLists.value - ?.hashtags - ?.toList() - - if (hashToLoad.isNullOrEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - SincePerRelayFilter( - kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND), - tags = - mapOf( - "t" to - hashToLoad - .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } - .flatten(), - ), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ) - } - - fun createLiveStreamGeohashesFilter(): TypedFilter? { - val hashToLoad = - account.liveDiscoveryFollowLists.value - ?.geotags - ?.toList() - - if (hashToLoad.isNullOrEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - SincePerRelayFilter( - kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND), - tags = mapOf("g" to hashToLoad), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ) - } - - fun createPublicChatsTagsFilter(): TypedFilter? { - val hashToLoad = - account.liveDiscoveryFollowLists.value - ?.hashtags - ?.toList() - - if (hashToLoad.isNullOrEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = - SincePerRelayFilter( - kinds = - listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND), - tags = - mapOf( - "t" to - hashToLoad - .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } - .flatten(), - ), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ) - } - - fun createPublicChatsGeohashesFilter(): TypedFilter? { - val hashToLoad = - account.liveDiscoveryFollowLists.value - ?.geotags - ?.toList() - - if (hashToLoad.isNullOrEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = - SincePerRelayFilter( - kinds = - listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND), - tags = - mapOf("g" to hashToLoad), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ) - } - - fun createCommunitiesTagsFilter(): TypedFilter? { - val hashToLoad = - account.liveDiscoveryFollowLists.value - ?.hashtags - ?.toList() - - if (hashToLoad.isNullOrEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - SincePerRelayFilter( - kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), - tags = - mapOf( - "t" to - hashToLoad - .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } - .flatten(), - ), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ) - } - - fun createCommunitiesGeohashesFilter(): TypedFilter? { - val hashToLoad = - account.liveDiscoveryFollowLists.value - ?.geotags - ?.toList() - - if (hashToLoad.isNullOrEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - SincePerRelayFilter( - kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), - tags = mapOf("g" to hashToLoad), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ) - } - - val discoveryFeedChannel = - requestNewChannel { time, relayUrl -> - latestEOSEs.addOrUpdate( - account.userProfile(), - account.settings.defaultDiscoveryFollowList.value, - relayUrl, - time, - ) - } - - override fun updateChannelFilters() { - discoveryFeedChannel.typedFilters = - createLiveStreamFilter() - .plus(createNIP89Filter(listOf("5300"))) - .plus(createPublicChatFilter()) - .plus(createMarketplaceFilter()) - .plus( - listOfNotNull( - createLiveStreamTagsFilter(), - createLiveStreamGeohashesFilter(), - createCommunitiesFilter(), - createCommunitiesTagsFilter(), - createCommunitiesGeohashesFilter(), - createPublicChatsTagsFilter(), - createPublicChatsGeohashesFilter(), - ), - ).toList() - .ifEmpty { null } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt deleted file mode 100644 index dc8f1b73d6..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt +++ /dev/null @@ -1,83 +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.service - -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent -import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent -import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent -import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent - -object NostrGeohashDataSource : AmethystNostrDataSource("SingleGeoHashFeed") { - private var geohashToWatch: String? = null - - fun createLoadHashtagFilter(): TypedFilter? { - val hashToLoad = geohashToWatch ?: return null - - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - tags = - mapOf( - "g" to - listOf( - hashToLoad, - ), - ), - kinds = - listOf( - TextNoteEvent.KIND, - ChannelMessageEvent.KIND, - LongTextNoteEvent.KIND, - PollNoteEvent.KIND, - ClassifiedsEvent.KIND, - HighlightEvent.KIND, - AudioTrackEvent.KIND, - AudioHeaderEvent.KIND, - WikiNoteEvent.KIND, - CommentEvent.KIND, - ), - limit = 200, - ), - ) - } - - val loadGeohashChannel = requestNewChannel() - - override fun updateChannelFilters() { - loadGeohashChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null } - } - - fun loadHashtag(tag: String?) { - geohashToWatch = tag - - invalidateFilters() - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt deleted file mode 100644 index 6e238a4574..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt +++ /dev/null @@ -1,102 +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.service - -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent -import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent -import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent -import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent -import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent -import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent - -object NostrHashtagDataSource : AmethystNostrDataSource("SingleHashtagFeed") { - private var hashtagToWatch: String? = null - - fun createLoadHashtagFilter(): List { - val hashToLoad = hashtagToWatch ?: return emptyList() - - val hashtagsToFollow = - listOf( - hashToLoad, - hashToLoad.lowercase(), - hashToLoad.uppercase(), - hashToLoad.capitalize(), - ) - - return listOf( - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - tags = mapOf("t" to hashtagsToFollow), - kinds = - listOf( - TextNoteEvent.KIND, - ChannelMessageEvent.KIND, - LongTextNoteEvent.KIND, - PollNoteEvent.KIND, - LiveActivitiesChatMessageEvent.KIND, - ClassifiedsEvent.KIND, - HighlightEvent.KIND, - AudioTrackEvent.KIND, - AudioHeaderEvent.KIND, - WikiNoteEvent.KIND, - CommentEvent.KIND, - ), - limit = 200, - ), - ), - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - tags = mapOf("t" to hashtagsToFollow), - kinds = - listOf( - InteractiveStorySceneEvent.KIND, - ), - limit = 200, - ), - ), - ) - } - - val loadHashtagChannel = requestNewChannel() - - override fun updateChannelFilters() { - loadHashtagChannel.typedFilters = createLoadHashtagFilter().ifEmpty { null } - } - - fun loadHashtag(tag: String?) { - hashtagToWatch = tag - - invalidateFilters() - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt deleted file mode 100644 index a6c0d40663..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt +++ /dev/null @@ -1,300 +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.service - -import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.relays.EOSEAccount -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent -import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent -import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent -import com.vitorpamplona.quartz.nip18Reposts.RepostEvent -import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip51Lists.PinListEvent -import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent -import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent -import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent -import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent -import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch - -object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { - lateinit var account: Account - - val scope = Amethyst.instance.applicationIOScope - val latestEOSEs = EOSEAccount() - - var job: Job? = null - var job2: Job? = null - - override fun start() { - job?.cancel() - job = - scope.launch(Dispatchers.IO) { - account.liveHomeFollowLists.collect { - if (this@NostrHomeDataSource::account.isInitialized) { - invalidateFilters() - } - } - } - - job2?.cancel() - job2 = - scope.launch(Dispatchers.IO) { - account.liveHomeListAuthorsPerRelay.collect { - if (this@NostrHomeDataSource::account.isInitialized) { - invalidateFilters() - } - } - } - super.start() - } - - override fun stop() { - super.stop() - job?.cancel() - job2?.cancel() - } - - fun createFollowAccountsFilter(): List { - val follows = - account.liveHomeListAuthorsPerRelay.value - - return listOf( - TypedFilter( - types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS), - filter = - SinceAuthorPerRelayFilter( - kinds = - listOf( - TextNoteEvent.KIND, - RepostEvent.KIND, - GenericRepostEvent.KIND, - ClassifiedsEvent.KIND, - LongTextNoteEvent.KIND, - PollNoteEvent.KIND, - HighlightEvent.KIND, - AudioTrackEvent.KIND, - AudioHeaderEvent.KIND, - PinListEvent.KIND, - ), - authors = follows, - limit = 400, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultHomeFollowList.value) - ?.relayList, - ), - ), - TypedFilter( - types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS), - filter = - SinceAuthorPerRelayFilter( - kinds = - listOf( - InteractiveStoryPrologueEvent.KIND, - LiveActivitiesChatMessageEvent.KIND, - LiveActivitiesEvent.KIND, - WikiNoteEvent.KIND, - ), - authors = follows, - limit = 400, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultHomeFollowList.value) - ?.relayList, - ), - ), - ) - } - - fun createFollowMetadataAndReleaseFilter(): TypedFilter? { - val follows = account.liveHomeListAuthorsPerRelay.value - - return if (!follows.isNullOrEmpty()) { - TypedFilter( - types = setOf(FeedType.FOLLOWS), - filter = - SinceAuthorPerRelayFilter( - kinds = - listOf( - MetadataEvent.KIND, - AdvertisedRelayListEvent.KIND, - ), - authors = follows, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultHomeFollowList.value) - ?.relayList, - ), - ) - } else { - null - } - } - - fun createFollowTagsFilter(): TypedFilter? { - val hashToLoad = account.liveHomeFollowLists.value?.hashtags ?: return null - - if (hashToLoad.isEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.FOLLOWS), - filter = - SincePerRelayFilter( - kinds = - listOf( - TextNoteEvent.KIND, - RepostEvent.KIND, - GenericRepostEvent.KIND, - LongTextNoteEvent.KIND, - ClassifiedsEvent.KIND, - HighlightEvent.KIND, - AudioHeaderEvent.KIND, - InteractiveStoryPrologueEvent.KIND, - CommentEvent.KIND, - WikiNoteEvent.KIND, - ), - tags = - mapOf( - "t" to - hashToLoad - .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } - .flatten(), - ), - limit = 100, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultHomeFollowList.value) - ?.relayList, - ), - ) - } - - fun createFollowGeohashesFilter(): TypedFilter? { - val hashToLoad = account.liveHomeFollowLists.value?.geotags ?: return null - - if (hashToLoad.isEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.FOLLOWS), - filter = - SincePerRelayFilter( - kinds = - listOf( - TextNoteEvent.KIND, - RepostEvent.KIND, - GenericRepostEvent.KIND, - LongTextNoteEvent.KIND, - ClassifiedsEvent.KIND, - HighlightEvent.KIND, - InteractiveStoryPrologueEvent.KIND, - WikiNoteEvent.KIND, - CommentEvent.KIND, - ), - tags = - mapOf( - "g" to hashToLoad.toList(), - ), - limit = 100, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultHomeFollowList.value) - ?.relayList, - ), - ) - } - - fun createFollowCommunitiesFilter(): TypedFilter? { - val communitiesToLoad = account.liveHomeFollowLists.value?.addresses ?: return null - - if (communitiesToLoad.isEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.FOLLOWS), - filter = - SincePerRelayFilter( - kinds = - listOf( - TextNoteEvent.KIND, - LongTextNoteEvent.KIND, - ClassifiedsEvent.KIND, - HighlightEvent.KIND, - WikiNoteEvent.KIND, - CommunityPostApprovalEvent.KIND, - CommentEvent.KIND, - InteractiveStoryPrologueEvent.KIND, - ), - tags = - mapOf( - "a" to communitiesToLoad.toList(), - ), - limit = 100, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultHomeFollowList.value) - ?.relayList, - ), - ) - } - - val followAccountChannel = - requestNewChannel { time, relayUrl -> - latestEOSEs.addOrUpdate( - account.userProfile(), - account.settings.defaultHomeFollowList.value, - relayUrl, - time, - ) - } - - override fun updateChannelFilters() { - followAccountChannel.typedFilters = - ( - createFollowAccountsFilter() + - listOfNotNull( - createFollowMetadataAndReleaseFilter(), - createFollowCommunitiesFilter(), - createFollowTagsFilter(), - createFollowGeohashesFilter(), - ) - ).ifEmpty { null } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt deleted file mode 100644 index 5ce56efb23..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt +++ /dev/null @@ -1,62 +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.service - -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent - -class NostrLnZapPaymentResponseDataSource( - private val fromServiceHex: String, - private val toUserHex: String, - private val replyingToHex: String, - private val authSigner: NostrSigner, -) : AmethystNostrDataSource("LnZapPaymentResponseFeed") { - val feedTypes = setOf(FeedType.WALLET_CONNECT) - - private fun createWalletConnectServiceWatcher(): TypedFilter { - // downloads all the reactions to a given event. - return TypedFilter( - types = feedTypes, - filter = - SincePerRelayFilter( - kinds = listOf(LnZapPaymentResponseEvent.KIND), - authors = listOf(fromServiceHex), - tags = - mapOf( - "e" to listOf(replyingToHex), - "p" to listOf(toUserHex), - ), - limit = 1, - ), - ) - } - - val channel = requestNewChannel() - - override fun updateChannelFilters() { - val wc = createWalletConnectServiceWatcher() - - channel.typedFilters = listOfNotNull(wc).ifEmpty { null } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt deleted file mode 100644 index 0b8a3ee7b2..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt +++ /dev/null @@ -1,232 +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.service - -import com.vitorpamplona.ammolite.relays.ALL_FEED_TYPES -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent -import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent -import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent -import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent -import com.vitorpamplona.quartz.experimental.nns.NNSEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent -import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray -import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser -import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress -import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed -import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent -import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile -import com.vitorpamplona.quartz.nip19Bech32.entities.NPub -import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay -import com.vitorpamplona.quartz.nip19Bech32.entities.NSec -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.nip30CustomEmoji.pack.EmojiPackEvent -import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent -import com.vitorpamplona.quartz.nip51Lists.PinListEvent -import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent -import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent -import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent -import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent -import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent -import com.vitorpamplona.quartz.utils.Hex -import kotlin.coroutines.cancellation.CancellationException - -object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFeed") { - private var searchString: String? = null - - private fun createAnythingWithIDFilter(): List? { - val mySearchString = searchString - if (mySearchString.isNullOrBlank()) { - return null - } - - val hexToWatch = - try { - val isAStraightHex = - if (Hex.isHex(mySearchString)) { - Hex.decode(mySearchString).toHexKey() - } else { - null - } - - when (val parsed = Nip19Parser.uriToRoute(mySearchString)?.entity) { - is NSec -> Nip01.pubKeyCreate(parsed.hex.hexToByteArray()).toHexKey() - is NPub -> parsed.hex - is NProfile -> parsed.hex - is com.vitorpamplona.quartz.nip19Bech32.entities.Note -> parsed.hex - is NEvent -> parsed.hex - is NEmbed -> parsed.event.id - is NRelay -> null - is NAddress -> parsed.aTag() - else -> isAStraightHex - } - } catch (e: Exception) { - if (e is CancellationException) throw e - null - } - - val directReferenceFilters = - hexToWatch?.let { - if (it.contains(":")) { - // naddr - listOfNotNull( - ATag.parse(it, null)?.let { aTag -> - TypedFilter( - types = ALL_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(MetadataEvent.KIND, aTag.kind), - authors = listOfNotNull(aTag.pubKeyHex), - // just to be sure - limit = 5, - ), - ) - }, - ) - } else { - // event ids - listOf( - TypedFilter( - types = ALL_FEED_TYPES, - filter = - SincePerRelayFilter( - ids = listOfNotNull(it), - ), - ), - // authors - TypedFilter( - types = ALL_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(MetadataEvent.KIND), - authors = listOfNotNull(it), - // just to be sure - limit = 5, - ), - ), - ) - } - } ?: emptyList() - - // downloads all the reactions to a given event. - return directReferenceFilters + - listOfNotNull( - TypedFilter( - types = setOf(FeedType.SEARCH), - filter = - SincePerRelayFilter( - kinds = listOf(MetadataEvent.KIND), - search = mySearchString, - limit = 1000, - ), - ), - TypedFilter( - types = setOf(FeedType.SEARCH), - filter = - SincePerRelayFilter( - kinds = - listOf( - TextNoteEvent.KIND, - LongTextNoteEvent.KIND, - BadgeDefinitionEvent.KIND, - PeopleListEvent.KIND, - BookmarkListEvent.KIND, - AudioHeaderEvent.KIND, - AudioTrackEvent.KIND, - PinListEvent.KIND, - PollNoteEvent.KIND, - ChannelCreateEvent.KIND, - ), - search = mySearchString, - limit = 100, - ), - ), - TypedFilter( - types = setOf(FeedType.SEARCH), - filter = - SincePerRelayFilter( - kinds = - listOf( - ChannelMetadataEvent.KIND, - ClassifiedsEvent.KIND, - CommunityDefinitionEvent.KIND, - EmojiPackEvent.KIND, - HighlightEvent.KIND, - LiveActivitiesEvent.KIND, - PollNoteEvent.KIND, - NNSEvent.KIND, - WikiNoteEvent.KIND, - CommentEvent.KIND, - ), - search = mySearchString, - limit = 100, - ), - ), - TypedFilter( - types = setOf(FeedType.SEARCH), - filter = - SincePerRelayFilter( - kinds = - listOf( - InteractiveStoryPrologueEvent.KIND, - InteractiveStorySceneEvent.KIND, - ), - search = mySearchString, - limit = 100, - ), - ), - ) - } - - val searchChannel = requestNewChannel() - - override fun updateChannelFilters() { - searchChannel.typedFilters = createAnythingWithIDFilter() - } - - fun search(searchString: String) { - if (this.searchString != searchString) { - println("DataSource: ${this.javaClass.simpleName} Search for $searchString") - this.searchString = searchString - invalidateFilters() - } - } - - fun clear() { - if (searchString != null) { - println("DataSource: ${this.javaClass.simpleName} Clear") - searchString = null - invalidateFilters() - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt deleted file mode 100644 index 93894a463f..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt +++ /dev/null @@ -1,125 +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.service - -import com.vitorpamplona.amethyst.model.Channel -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel -import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent -import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent - -object NostrSingleChannelDataSource : AmethystNostrDataSource("SingleChannelFeed") { - private var channelsToWatch = setOf() - - private fun createMetadataChangeFilter(): TypedFilter? { - val reactionsToWatch = channelsToWatch.filter { it is PublicChatChannel }.map { it.idHex } - - if (reactionsToWatch.isEmpty()) { - return null - } - - // downloads all the reactions to a given event. - return TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = - SincePerRelayFilter( - kinds = listOf(ChannelMetadataEvent.KIND), - tags = mapOf("e" to reactionsToWatch), - ), - ) - } - - fun createLoadEventsIfNotLoadedFilter(): TypedFilter? { - val directEventsToLoad = - channelsToWatch.filter { it.notes.isEmpty() && it is PublicChatChannel } - - val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet() - - if (interestedEvents.isEmpty()) { - return null - } - - // downloads linked events to this event. - return TypedFilter( - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(ChannelCreateEvent.KIND), - ids = interestedEvents.toList(), - ), - ) - } - - fun createLoadStreamingIfNotLoadedFilter(): List? { - val directEventsToLoad = - channelsToWatch.filterIsInstance().filter { it.info == null } - - val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet() - - if (interestedEvents.isEmpty()) { - return null - } - - // downloads linked events to this event. - return directEventsToLoad.map { - it.address().let { aTag -> - TypedFilter( - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(aTag.kind), - tags = mapOf("d" to listOf(aTag.dTag)), - authors = listOf(aTag.pubKeyHex), - ), - ) - } - } - } - - val singleChannelChannel = requestNewChannel() - - override fun updateChannelFilters() { - val reactions = createMetadataChangeFilter() - val missing = createLoadEventsIfNotLoadedFilter() - val missingStreaming = createLoadStreamingIfNotLoadedFilter() - - singleChannelChannel.typedFilters = - ((listOfNotNull(reactions, missing)) + (missingStreaming ?: emptyList())).ifEmpty { null } - } - - fun add(eventId: Channel) { - if (eventId !in channelsToWatch) { - channelsToWatch = channelsToWatch.plus(eventId) - invalidateFilters() - } - } - - fun remove(eventId: Channel) { - if (eventId in channelsToWatch) { - channelsToWatch = channelsToWatch.minus(eventId) - invalidateFilters() - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt deleted file mode 100644 index 7ba8259dac..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ /dev/null @@ -1,371 +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.service - -import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent -import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent -import com.vitorpamplona.quartz.nip18Reposts.RepostEvent -import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent -import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent -import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent -import com.vitorpamplona.quartz.nip56Reports.ReportEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent -import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent - -object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") { - private var nextEventsToWatch = setOf() - private var nextAddressesToWatch = setOf() - - private var eventsToWatchInProd = setOf() - private var addressesToWatchInProd = setOf() - - private fun createReactionsToWatchInAddressFilter(): List? { - val myAddressesToWatch = - ( - eventsToWatchInProd.filter { it.address() != null } + - addressesToWatchInProd.filter { it.address() != null } - ).toSet() - - if (myAddressesToWatch.isEmpty()) { - return null - } - - return groupByEOSEPresence(myAddressesToWatch) - .map { - listOf( - TypedFilter( - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - TextNoteEvent.KIND, - ReactionEvent.KIND, - RepostEvent.KIND, - GenericRepostEvent.KIND, - ReportEvent.KIND, - LnZapEvent.KIND, - PollNoteEvent.KIND, - CommunityPostApprovalEvent.KIND, - LiveActivitiesChatMessageEvent.KIND, - ), - tags = mapOf("a" to it.mapNotNull { it.address()?.toValue() }), - since = findMinimumEOSEs(it), - // Max amount of "replies" to download on a specific event. - limit = 1000, - ), - ), - TypedFilter( - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - DeletionEvent.KIND, - ), - tags = mapOf("a" to it.mapNotNull { it.address()?.toValue() }), - since = findMinimumEOSEs(it), - // Max amount of "replies" to download on a specific event. - limit = 10, - ), - ), - ) - }.flatten() - } - - private fun createAddressFilter(): List? { - val myAddressesToWatch = addressesToWatchInProd.filter { it.event == null } - - if (myAddressesToWatch.isEmpty()) { - return null - } - - return myAddressesToWatch.mapNotNull { - it.address()?.let { aTag -> - if (aTag.kind < 25000 && aTag.dTag.isBlank()) { - TypedFilter( - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(aTag.kind), - authors = listOf(aTag.pubKeyHex), - limit = 5, - ), - ) - } else { - TypedFilter( - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(aTag.kind), - tags = mapOf("d" to listOf(aTag.dTag)), - authors = listOf(aTag.pubKeyHex), - limit = 5, - ), - ) - } - } - } - } - - private fun createRepliesAndReactionsFilter(): List? { - if (eventsToWatchInProd.isEmpty()) { - return null - } - - return groupByEOSEPresence(eventsToWatchInProd) - .map { - listOf( - TypedFilter( - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - TextNoteEvent.KIND, - ReactionEvent.KIND, - RepostEvent.KIND, - GenericRepostEvent.KIND, - ReportEvent.KIND, - LnZapEvent.KIND, - PollNoteEvent.KIND, - OtsEvent.KIND, - TextNoteModificationEvent.KIND, - GitReplyEvent.KIND, - ), - tags = mapOf("e" to it.map { it.idHex }), - since = findMinimumEOSEs(it), - // Max amount of "replies" to download on a specific event. - limit = 10000, - ), - ), - TypedFilter( - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - DeletionEvent.KIND, - NIP90ContentDiscoveryResponseEvent.KIND, - NIP90StatusEvent.KIND, - TorrentCommentEvent.KIND, - ), - tags = mapOf("e" to it.map { it.idHex }), - since = findMinimumEOSEs(it), - limit = 100, - ), - ), - ) - }.flatten() - } - - private fun createQuotesFilter(): List? { - if (eventsToWatchInProd.isEmpty()) { - return null - } - - return groupByEOSEPresence(eventsToWatchInProd) - .map { - listOf( - TypedFilter( - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(TextNoteEvent.KIND), - tags = mapOf("q" to it.map { it.idHex }), - since = findMinimumEOSEs(it), - // Max amount of "replies" to download on a specific event. - limit = 1000, - ), - ), - ) - }.flatten() - } - - fun createLoadEventsIfNotLoadedFilter(): List? { - val directEventsToLoad = eventsToWatchInProd.filter { it.event == null } - - val threadingEventsToLoad = - eventsToWatchInProd - .mapNotNull { it.replyTo } - .flatten() - .filter { it !is AddressableNote && it.event == null } - - val interestedEvents = (directEventsToLoad + threadingEventsToLoad).map { it.idHex }.toSet() - - if (interestedEvents.isEmpty()) { - return null - } - - // downloads linked events to this event. - return listOf( - TypedFilter( - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - ids = interestedEvents.toList(), - ), - ), - ) - } - - val singleEventChannel = - requestNewChannel { time, relayUrl -> - // Ignores EOSE if it is in the middle of a filter change. - if (changingFilters.get()) return@requestNewChannel - - checkNotInMainThread() - - eventsToWatchInProd.forEach { - val eose = it.lastReactionsDownloadTime[relayUrl] - if (eose == null) { - it.lastReactionsDownloadTime += Pair(relayUrl, EOSETime(time)) - } else { - eose.time = time - } - } - - addressesToWatchInProd.forEach { - val eose = it.lastReactionsDownloadTime[relayUrl] - if (eose == null) { - it.lastReactionsDownloadTime += Pair(relayUrl, EOSETime(time)) - } else { - eose.time = time - } - } - - // Many relays operate with limits in the amount of filters. - // As information comes, the filters will be rotated to get more data. - invalidateFilters() - } - - override fun updateChannelFilters() { - addressesToWatchInProd = nextAddressesToWatch - eventsToWatchInProd = nextEventsToWatch - - val reactions = createRepliesAndReactionsFilter() - val missing = createLoadEventsIfNotLoadedFilter() - val addresses = createAddressFilter() - val addressReactions = createReactionsToWatchInAddressFilter() - val quotes = createQuotesFilter() - - singleEventChannel.typedFilters = - listOfNotNull(missing, addresses, reactions, addressReactions, quotes).flatten().ifEmpty { null } - } - - fun add(eventId: Note) { - if (!nextEventsToWatch.contains(eventId)) { - nextEventsToWatch = nextEventsToWatch.plus(eventId) - invalidateFilters() - } - } - - fun remove(eventId: Note) { - if (nextEventsToWatch.contains(eventId)) { - nextEventsToWatch = nextEventsToWatch.minus(eventId) - invalidateFilters() - } - } - - fun addAddress(addressableNote: Note) { - if (!nextAddressesToWatch.contains(addressableNote)) { - nextAddressesToWatch = nextAddressesToWatch.plus(addressableNote) - invalidateFilters() - } - } - - fun removeAddress(addressableNote: Note) { - if (nextAddressesToWatch.contains(addressableNote)) { - nextAddressesToWatch = nextAddressesToWatch.minus(addressableNote) - invalidateFilters() - } - } -} - -fun groupByEOSEPresence(notes: Set): Collection> = - notes - .groupBy { - it.lastReactionsDownloadTime.keys - .sorted() - .joinToString(",") - }.values - .map { - it.sortedBy { it.idHex } // important to keep in order otherwise the Relay thinks the filter has changed and we REQ again - } - -fun groupByEOSEPresence(users: Iterable): Collection> = - users - .groupBy { - it.latestEOSEs.keys - .sorted() - .joinToString(",") - }.values - .map { - it.sortedBy { it.pubkeyHex } // important to keep in order otherwise the Relay thinks the filter has changed and we REQ again - } - -fun findMinimumEOSEs(notes: List): Map { - val minLatestEOSEs = mutableMapOf() - - notes.forEach { note -> - note.lastReactionsDownloadTime.forEach { - val minEose = minLatestEOSEs[it.key] - if (minEose == null) { - minLatestEOSEs.put(it.key, EOSETime(it.value.time)) - } else if (it.value.time < minEose.time) { - minEose.time = it.value.time - } - } - } - - return minLatestEOSEs -} - -fun findMinimumEOSEsForUsers(users: List): Map { - val minLatestEOSEs = mutableMapOf() - - users.forEach { - it.latestEOSEs.forEach { - val minEose = minLatestEOSEs[it.key] - if (minEose == null) { - minLatestEOSEs.put(it.key, EOSETime(it.value.time)) - } else if (it.value.time < minEose.time) { - minEose.time = it.value.time - } - } - } - - return minLatestEOSEs -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt deleted file mode 100644 index 8e0f28c1bc..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt +++ /dev/null @@ -1,134 +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.service - -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.experimental.relationshipStatus.RelationshipStatusEvent -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent -import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent -import com.vitorpamplona.quartz.nip56Reports.ReportEvent -import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent - -object NostrSingleUserDataSource : AmethystNostrDataSource("SingleUserFeed") { - private var usersToWatch = setOf() - - fun createUserMetadataFilter(): List? { - if (usersToWatch.isEmpty()) return null - - val firstTimers = usersToWatch.filter { it.latestMetadata == null }.map { it.pubkeyHex } - - if (firstTimers.isEmpty()) return null - - return listOf( - TypedFilter( - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(MetadataEvent.KIND, AdvertisedRelayListEvent.KIND), - authors = firstTimers, - ), - ), - ) - } - - fun createUserMetadataStatusReportFilter(): List? { - if (usersToWatch.isEmpty()) return null - - val secondTimers = usersToWatch.filter { it.latestMetadata != null } - - if (secondTimers.isEmpty()) return null - - return groupByEOSEPresence(secondTimers) - .map { group -> - val groupIds = group.map { it.pubkeyHex } - val minEOSEs = findMinimumEOSEsForUsers(group) - - if (groupIds.isNotEmpty()) { - listOf( - TypedFilter( - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(MetadataEvent.KIND, StatusEvent.KIND, RelationshipStatusEvent.KIND, AdvertisedRelayListEvent.KIND, ChatMessageRelayListEvent.KIND), - authors = groupIds, - since = minEOSEs, - ), - ), - TypedFilter( - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(ReportEvent.KIND), - tags = mapOf("p" to groupIds), - since = minEOSEs, - ), - ), - ) - } else { - listOf() - } - }.flatten() - } - - val userChannel = - requestNewChannel { time, relayUrl -> - checkNotInMainThread() - - usersToWatch.forEach { - val eose = it.latestEOSEs[relayUrl] - if (eose == null) { - it.latestEOSEs = it.latestEOSEs + Pair(relayUrl, EOSETime(time)) - } else { - eose.time = time - } - } - } - - override fun updateChannelFilters() { - checkNotInMainThread() - - userChannel.typedFilters = - listOfNotNull( - createUserMetadataFilter(), - createUserMetadataStatusReportFilter(), - ).flatten() - .ifEmpty { null } - } - - fun add(user: User) { - if (!usersToWatch.contains(user)) { - usersToWatch = usersToWatch.plus(user) - invalidateFilters() - } - } - - fun remove(user: User) { - if (usersToWatch.contains(user)) { - usersToWatch = usersToWatch.minus(user) - invalidateFilters() - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt deleted file mode 100644 index 8a740c138d..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt +++ /dev/null @@ -1,102 +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.service - -import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.model.ThreadAssembler -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter - -object NostrThreadDataSource : AmethystNostrDataSource("SingleThreadFeed") { - private var eventToWatch: String? = null - - fun createLoadEventsIfNotLoadedFilter(): List { - val threadToLoad = eventToWatch ?: return emptyList() - - val branch = ThreadAssembler().findThreadFor(threadToLoad) ?: return emptyList() - - val eventsToLoad = - branch.allNotes - .filter { it.event == null } - .map { it.idHex } - .toSet() - .ifEmpty { null } - - val address = if (branch.root is AddressableNote) branch.root.idHex else null - val event = if (branch.root !is AddressableNote) branch.root.idHex else branch.root.event?.id - - return listOfNotNull( - eventsToLoad?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - ids = it.toList(), - ), - ) - }, - event?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - tags = - mapOf( - "e" to listOf(event), - ), - ), - ) - }, - address?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - tags = - mapOf( - "a" to listOf(address), - ), - ), - ) - }, - ) - } - - val loadEventsChannel = - requestNewChannel { _, _ -> - // Many relays operate with limits in the amount of filters. - // As information comes, the filters will be rotated to get more data. - invalidateFilters() - } - - override fun updateChannelFilters() { - loadEventsChannel.typedFilters = createLoadEventsIfNotLoadedFilter() - } - - fun loadThread(noteId: String?) { - if (eventToWatch != noteId) { - eventToWatch = noteId - - invalidateFilters() - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt deleted file mode 100644 index f07dcfd243..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt +++ /dev/null @@ -1,227 +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.service - -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent -import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent -import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent -import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent -import com.vitorpamplona.quartz.nip18Reposts.RepostEvent -import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent -import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent -import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent -import com.vitorpamplona.quartz.nip51Lists.PinListEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent -import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent -import com.vitorpamplona.quartz.nip68Picture.PictureEvent -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent -import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent -import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent - -object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") { - var user: User? = null - - fun loadUserProfile(user: User?) { - this.user = user - } - - fun createUserInfoFilter() = - user?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(MetadataEvent.KIND), - authors = listOf(it.pubkeyHex), - limit = 1, - ), - ) - } - - fun createUserPostsFilter() = - user?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - TextNoteEvent.KIND, - GenericRepostEvent.KIND, - RepostEvent.KIND, - LongTextNoteEvent.KIND, - AudioTrackEvent.KIND, - AudioHeaderEvent.KIND, - PinListEvent.KIND, - PollNoteEvent.KIND, - HighlightEvent.KIND, - WikiNoteEvent.KIND, - ), - authors = listOf(it.pubkeyHex), - limit = 200, - ), - ) - } - - fun createUserPostsFilter2() = - user?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - TorrentEvent.KIND, - TorrentCommentEvent.KIND, - InteractiveStoryPrologueEvent.KIND, - CommentEvent.KIND, - ), - authors = listOf(it.pubkeyHex), - limit = 50, - ), - ) - } - - fun createUserReceivedZapsFilter() = - user?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(LnZapEvent.KIND), - tags = mapOf("p" to listOf(it.pubkeyHex)), - limit = 200, - ), - ) - } - - fun createFollowFilter() = - user?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(ContactListEvent.KIND), - authors = listOf(it.pubkeyHex), - limit = 1, - ), - ) - } - - fun createFollowersFilter() = - user?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(ContactListEvent.KIND), - tags = mapOf("p" to listOf(it.pubkeyHex)), - ), - ) - } - - fun createAcceptedAwardsFilter() = - user?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(BadgeProfilesEvent.KIND), - authors = listOf(it.pubkeyHex), - limit = 1, - ), - ) - } - - fun createBookmarksFilter() = - user?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, AppRecommendationEvent.KIND), - authors = listOf(it.pubkeyHex), - limit = 100, - ), - ) - } - - fun createProfileGalleryFilter() = - user?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf(ProfileGalleryEntryEvent.KIND, PictureEvent.KIND, VideoVerticalEvent.KIND, VideoHorizontalEvent.KIND), - authors = listOf(it.pubkeyHex), - limit = 1000, - ), - ) - } - - fun createReceivedAwardsFilter() = - user?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(BadgeAwardEvent.KIND), - tags = mapOf("p" to listOf(it.pubkeyHex)), - limit = 20, - ), - ) - } - - val userInfoChannel = requestNewChannel() - - override fun updateChannelFilters() { - userInfoChannel.typedFilters = - listOfNotNull( - createUserInfoFilter(), - createUserPostsFilter(), - createUserPostsFilter2(), - createProfileGalleryFilter(), - createFollowFilter(), - createFollowersFilter(), - createUserReceivedZapsFilter(), - createAcceptedAwardsFilter(), - createReceivedAwardsFilter(), - createBookmarksFilter(), - ).ifEmpty { null } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt deleted file mode 100644 index f0a0fa46c4..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt +++ /dev/null @@ -1,219 +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.service - -import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.relays.EOSEAccount -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent -import com.vitorpamplona.quartz.nip68Picture.PictureEvent -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch - -val SUPPORTED_VIDEO_FEED_MIME_TYPES = listOf("image/jpeg", "image/gif", "image/png", "image/webp", "video/mp4", "video/mpeg", "video/webm", "audio/aac", "audio/mpeg", "audio/webm", "audio/wav", "image/avif") -val SUPPORTED_VIDEO_FEED_MIME_TYPES_SET = SUPPORTED_VIDEO_FEED_MIME_TYPES.toSet() - -object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") { - lateinit var account: Account - - val scope = Amethyst.instance.applicationIOScope - val latestEOSEs = EOSEAccount() - - var job: Job? = null - - override fun start() { - job?.cancel() - job = - scope.launch(Dispatchers.IO) { - account.liveStoriesFollowLists.collect { - if (this@NostrVideoDataSource::account.isInitialized) { - invalidateFilters() - } - } - } - super.start() - } - - override fun stop() { - super.stop() - job?.cancel() - } - - fun createContextualFilter(): List { - val follows = account.liveStoriesListAuthorsPerRelay.value - - val types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS) - - return listOf( - TypedFilter( - types = types, - filter = - SinceAuthorPerRelayFilter( - authors = follows, - kinds = listOf(PictureEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND), - limit = 200, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultStoriesFollowList.value) - ?.relayList, - ), - ), - TypedFilter( - types = types, - filter = - SinceAuthorPerRelayFilter( - authors = follows, - kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND), - limit = 200, - tags = mapOf("m" to SUPPORTED_VIDEO_FEED_MIME_TYPES), - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultStoriesFollowList.value) - ?.relayList, - ), - ), - ) - } - - fun createFollowTagsFilter(): List { - val hashToLoad = - account.liveStoriesFollowLists.value - ?.hashtags - ?.toList() ?: return emptyList() - - if (hashToLoad.isEmpty()) return emptyList() - - val hashtags = - hashToLoad - .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } - .flatten() - - return listOf( - TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - SincePerRelayFilter( - kinds = listOf(PictureEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND), - tags = mapOf("t" to hashtags), - limit = 100, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultStoriesFollowList.value) - ?.relayList, - ), - ), - TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - SincePerRelayFilter( - kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND), - tags = - mapOf( - "t" to hashtags, - "m" to SUPPORTED_VIDEO_FEED_MIME_TYPES, - ), - limit = 100, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultStoriesFollowList.value) - ?.relayList, - ), - ), - ) - } - - fun createFollowGeohashesFilter(): List { - val hashToLoad = - account.liveStoriesFollowLists.value - ?.geotags - ?.toList() ?: return emptyList() - - if (hashToLoad.isEmpty()) return emptyList() - - val geoHashes = hashToLoad - - return listOf( - TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - SincePerRelayFilter( - kinds = listOf(PictureEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND), - tags = mapOf("g" to geoHashes), - limit = 100, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultStoriesFollowList.value) - ?.relayList, - ), - ), - TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - SincePerRelayFilter( - kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND), - tags = - mapOf( - "g" to geoHashes, - "m" to SUPPORTED_VIDEO_FEED_MIME_TYPES, - ), - limit = 100, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultStoriesFollowList.value) - ?.relayList, - ), - ), - ) - } - - val videoFeedChannel = - requestNewChannel { time, relayUrl -> - latestEOSEs.addOrUpdate( - account.userProfile(), - account.settings.defaultStoriesFollowList.value, - relayUrl, - time, - ) - } - - override fun updateChannelFilters() { - videoFeedChannel.typedFilters = - listOfNotNull( - createContextualFilter(), - createFollowTagsFilter(), - createFollowGeohashesFilter(), - ).flatten().ifEmpty { null } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt index 78c7b92e2b..04f21d2fdb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt @@ -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,12 +23,12 @@ package com.vitorpamplona.amethyst.service import android.util.Log import android.util.LruCache import androidx.compose.runtime.Immutable -import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.quartz.utils.RandomInstance import okhttp3.EventListener import okhttp3.OkHttpClient import okhttp3.Protocol import okhttp3.Request +import okhttp3.coroutines.executeAsync import okio.ByteString.Companion.toByteString import kotlin.coroutines.cancellation.CancellationException @@ -49,12 +49,10 @@ object OnlineChecker { return false } - fun isOnline( + suspend fun isOnline( url: String?, - okttpClient: (String) -> OkHttpClient, + okHttpClient: (String) -> OkHttpClient, ): Boolean { - checkNotInMainThread() - if (url.isNullOrBlank()) return false if ((checkOnlineCache.get(url)?.timeInMs ?: 0) > System.currentTimeMillis() - fiveMinutes) { return checkOnlineCache.get(url).online @@ -66,7 +64,6 @@ object OnlineChecker { val request = Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(url.replace("wss+livekit://", "wss://")) .header("Upgrade", "websocket") .header("Connection", "Upgrade") @@ -76,29 +73,23 @@ object OnlineChecker { .build() val client = - okttpClient(url) + okHttpClient(url) .newBuilder() .eventListener(EventListener.NONE) .protocols(listOf(Protocol.HTTP_1_1)) .build() - client.newCall(request).execute().use { - checkNotInMainThread() - it.isSuccessful - } + client.newCall(request).executeAsync().use { it.isSuccessful } } else { val request = Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(url) .get() .build() + val client = okHttpClient(url) - okttpClient(url).newCall(request).execute().use { - checkNotInMainThread() - it.isSuccessful - } + client.newCall(request).executeAsync().use { it.isSuccessful } } checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/PackageUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/PackageUtils.kt index 736911a541..e21d1759b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/PackageUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/PackageUtils.kt @@ -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,11 +20,11 @@ */ package com.vitorpamplona.amethyst.service +import android.annotation.SuppressLint import android.content.Context -import android.content.Intent -import android.net.Uri object PackageUtils { + @SuppressLint("QueryPermissionsNeeded") private fun isPackageInstalled( context: Context, target: String, @@ -34,14 +34,4 @@ object PackageUtils { } != null fun isOrbotInstalled(context: Context): Boolean = isPackageInstalled(context, "org.torproject.android") - - fun isExternalSignerInstalled(context: Context): Boolean { - val intent = - Intent().apply { - action = Intent.ACTION_VIEW - data = Uri.parse("nostrsigner:") - } - val infos = context.packageManager.queryIntentActivities(intent, 0) - return infos.size > 0 - } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/SetExt.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/SetExt.kt index 03f1a5c350..9676bf1bef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/SetExt.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/SetExt.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt index 30530b4a57..42adc194f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -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,25 +23,25 @@ package com.vitorpamplona.amethyst.service import android.content.Context import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.collectSuccessfulOperations import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource.user import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip57Zaps.splits.BaseZapSplitSetup +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupLnAddress import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup -import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.utils.mapNotNullAsync import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient @@ -52,12 +52,25 @@ class ZapPaymentHandler( ) { @Immutable data class Payable( - val info: BaseZapSplitSetup, - val user: User?, + val info: MyZapSplitSetup, val amountMilliSats: Long, val invoice: String, ) + data class UnverifiedZapSplitSetup( + val lnAddress: String?, + val weight: Double = 1.0, + val relay: NormalizedRelayUrl? = null, + val user: User? = null, + ) + + data class MyZapSplitSetup( + val lnAddress: String, + val weight: Double = 1.0, + val relay: NormalizedRelayUrl? = null, + val user: User? = null, + ) + suspend fun zap( note: Note, amountMilliSats: Long, @@ -74,87 +87,118 @@ class ZapPaymentHandler( val noteEvent = note.event val zapSplitSetup = noteEvent?.zapSplitSetup() - val zapsToSend = + val unverifiedZapsToSend = if (!zapSplitSetup.isNullOrEmpty()) { - zapSplitSetup + zapSplitSetup.map { setup -> + when (setup) { + is ZapSplitSetupLnAddress -> { + UnverifiedZapSplitSetup( + lnAddress = setup.lnAddress, + weight = setup.weight, + ) + } + is ZapSplitSetup -> { + val user = LocalCache.checkGetOrCreateUser(setup.pubKeyHex) + UnverifiedZapSplitSetup( + lnAddress = user?.info?.lnAddress(), + weight = setup.weight, + relay = setup.relay, + user = user, + ) + } + } + } } else if (noteEvent is LiveActivitiesEvent && noteEvent.hasHost()) { - noteEvent.hosts().map { ZapSplitSetup(it.pubKey, it.relayHint, weight = 1.0) } + noteEvent.hosts().map { + val user = LocalCache.checkGetOrCreateUser(it.pubKey) + val lnAddress = user?.info?.lnAddress() + UnverifiedZapSplitSetup(lnAddress, relay = it.relayHint, user = user) + } } else if (noteEvent is AppDefinitionEvent) { val appLud16 = noteEvent.appMetaData()?.lnAddress() if (appLud16 != null) { - listOf(ZapSplitSetupLnAddress(appLud16, weight = 1.0)) + listOf(UnverifiedZapSplitSetup(appLud16)) } else { val lud16 = note.author?.info?.lnAddress() - - if (lud16.isNullOrBlank()) { - if (showErrorIfNoLnAddress) { - onError( - stringRes(context, R.string.missing_lud16), - stringRes( - context, - R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats, - ), - note.author, - ) - } - return@withContext - } - - listOf(ZapSplitSetupLnAddress(lud16, weight = 1.0)) + listOf(UnverifiedZapSplitSetup(lud16)) } } else { - val lud16 = note.author?.info?.lnAddress() + listOf(UnverifiedZapSplitSetup(note.author?.info?.lnAddress())) + } - if (lud16.isNullOrBlank()) { - if (showErrorIfNoLnAddress) { - onError( - stringRes(context, R.string.missing_lud16), - stringRes( - context, - R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats, - ), - note.author, + if (showErrorIfNoLnAddress) { + val errors = unverifiedZapsToSend.filter { it.lnAddress.isNullOrBlank() } + errors.forEach { + val message = + if (it.user != null) { + stringRes( + context, + R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats, + it.user.toBestDisplayName(), ) + } else { + stringRes(context, R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats) } - return@withContext - } - listOf(ZapSplitSetupLnAddress(lud16, weight = 1.0)) + onError( + stringRes(context, R.string.missing_lud16), + message, + it.user, + ) + } + } + + val zapsToSend = + unverifiedZapsToSend.mapNotNull { + if (it.lnAddress != null) { + MyZapSplitSetup( + it.lnAddress, + it.weight, + it.relay, + it.user, + ) + } else { + null + } } onProgress(0.02f) - signAllZapRequests(note, pollOption, message, zapType, zapsToSend) { splitZapRequestPairs -> - if (splitZapRequestPairs.isEmpty()) { - onProgress(0.00f) - return@signAllZapRequests - } else { - onProgress(0.05f) - } - assembleAllInvoices(splitZapRequestPairs, amountMilliSats, message, showErrorIfNoLnAddress, okHttpClient, onError, onProgress = { - onProgress(it * 0.7f + 0.05f) // keeps within range. - }, context) { payables -> - if (payables.isEmpty()) { - onProgress(0.00f) - return@assembleAllInvoices - } else { - onProgress(0.75f) - } + val splitZapRequests = signAllZapRequests(note, pollOption, message, zapType, zapsToSend) - if (account.hasWalletConnectSetup()) { - payViaNWC(payables, note, onError = onError, onProgress = { - onProgress(it * 0.25f + 0.75f) // keeps within range. - }, context) { - // onProgress(1f) - } - } else { - onPayViaIntent( - payables.toImmutableList(), - ) + if (splitZapRequests.isEmpty()) { + onProgress(0.00f) + return@withContext + } else { + onProgress(0.05f) + } - onProgress(0f) - } - } + val payables = + assembleAllInvoices( + requests = splitZapRequests, + totalAmountMilliSats = amountMilliSats, + message = message, + okHttpClient = okHttpClient, + onError = onError, + onProgress = { onProgress(it * 0.7f + 0.05f) }, + context = context, + ) + + if (payables.isEmpty()) { + onProgress(0.00f) + return@withContext + } else { + onProgress(0.75f) + } + + if (account.nip47SignerState.hasWalletConnectSetup()) { + payViaNWC(payables, note, onError = onError, onProgress = { + onProgress(it * 0.25f + 0.75f) // keeps within range. + }, context) + // onProgress(1f) + } else { + onPayViaIntent(payables.toImmutableList()) + onProgress(0f) } } @@ -169,9 +213,8 @@ class ZapPaymentHandler( } class ZapRequestReady( - val inputSetup: BaseZapSplitSetup, - val zapRequestJson: String?, - val user: User? = null, + val inputSetup: MyZapSplitSetup, + val zapRequest: LnZapRequestEvent?, ) suspend fun signAllZapRequests( @@ -179,87 +222,74 @@ class ZapPaymentHandler( pollOption: Int?, message: String, zapType: LnZapEvent.ZapType, - zapsToSend: List, - onAllDone: suspend (List) -> Unit, - ) { - val authorRelayList = - note.author - ?.pubkeyHex - ?.let { - ( - LocalCache - .getAddressableNoteIfExists( - AdvertisedRelayListEvent.createAddressTag(it), - )?.event as? AdvertisedRelayListEvent? - )?.readRelays() - }?.toSet() + zapsToSend: List, + ): List = + mapNotNullAsync(zapsToSend) { next: MyZapSplitSetup -> + // makes sure the author receives the zap event + val authorRelayList = note.author?.inboxRelays()?.toSet() ?: emptySet() - collectSuccessfulOperations( - items = zapsToSend, - runRequestFor = { next: BaseZapSplitSetup, onReady -> - if (next is ZapSplitSetupLnAddress) { - prepareZapRequestIfNeeded(note, pollOption, message, zapType) { zapRequestJson -> - if (zapRequestJson != null) { - onReady(ZapRequestReady(next, zapRequestJson)) - } - } - } else if (next is ZapSplitSetup) { - val user = LocalCache.getUserIfExists(next.pubKeyHex) - val userRelayList = - ( - ( - LocalCache - .getAddressableNoteIfExists( - AdvertisedRelayListEvent.createAddressTag(next.pubKeyHex), - )?.event as? AdvertisedRelayListEvent? - )?.readRelays()?.toSet() ?: emptySet() - ) + (authorRelayList ?: emptySet()) + // makes sure the zap split user receives the zap event + val userRelayList = next.user?.inboxRelays()?.toSet() ?: emptySet() - prepareZapRequestIfNeeded(note, pollOption, message, zapType, user, userRelayList) { zapRequestJson -> - onReady(ZapRequestReady(next, zapRequestJson, user)) - } + val noteEvent = note.event + + val zapRequest = + if (zapType != LnZapEvent.ZapType.NONZAP && noteEvent != null) { + account.createZapRequestFor(noteEvent, pollOption, message, zapType, next.user, userRelayList + authorRelayList) + } else { + null } - }, - onReady = onAllDone, - ) - } + + ZapRequestReady(next, zapRequest) + } suspend fun assembleAllInvoices( requests: List, totalAmountMilliSats: Long, message: String, - showErrorIfNoLnAddress: Boolean, okHttpClient: (String) -> OkHttpClient, onError: (String, String, User?) -> Unit, onProgress: (percent: Float) -> Unit, context: Context, - onAllDone: suspend (List) -> Unit, - ) { + ): List { var progressAllPayments = 0.00f val totalWeight = requests.sumOf { it.inputSetup.weight } - collectSuccessfulOperations( - items = requests, - runRequestFor = { splitZapRequestPair: ZapRequestReady, onReady -> + return mapNotNullAsync(requests) { splitZapRequestPair: ZapRequestReady -> + try { assembleInvoice( + lud16 = splitZapRequestPair.inputSetup.lnAddress, splitSetup = splitZapRequestPair.inputSetup, - nostrZapRequest = splitZapRequestPair.zapRequestJson, - toUser = splitZapRequestPair.user, + nostrZapRequest = splitZapRequestPair.zapRequest, zapValue = calculateZapValue(totalAmountMilliSats, splitZapRequestPair.inputSetup.weight, totalWeight), message = message, - showErrorIfNoLnAddress = showErrorIfNoLnAddress, okHttpClient = okHttpClient, - onError = onError, onProgressStep = { percentStepForThisPayment -> progressAllPayments += percentStepForThisPayment / requests.size onProgress(progressAllPayments) }, context = context, - onReady = onReady, ) - }, - onReady = onAllDone, - ) + } catch (e: LightningAddressResolver.LightningAddressError) { + onError(e.title, e.msg, splitZapRequestPair.inputSetup.user) + null + } catch (e: Exception) { + if (e is CancellationException) throw e + onError( + stringRes( + context, + R.string.error_unable_to_fetch_invoice, + ), + stringRes( + context, + R.string.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error, + e.message, + ), + null, + ) + null + } + } } class Paid( @@ -273,21 +303,15 @@ class ZapPaymentHandler( onError: (String, String, User?) -> Unit, onProgress: (percent: Float) -> Unit, context: Context, - onAllDone: suspend (List) -> Unit, - ) { + ): List { var progressAllPayments = 0.00f - collectSuccessfulOperations( + return mapNotNullAsync( items = payables, - runRequestFor = { payable: Payable, onReady -> + runRequestFor = { payable: Payable -> account.sendZapPaymentRequestFor( bolt11 = payable.invoice, zappedNote = note, - onSent = { - progressAllPayments += 0.5f / payables.size - onProgress(progressAllPayments) - onReady(Paid(payable, true)) - }, onResponse = { response -> if (response is PayInvoiceErrorResponse) { progressAllPayments += 0.5f / payables.size @@ -300,7 +324,7 @@ class ZapPaymentHandler( response.error?.message ?: response.error?.code?.toString() ?: "Error parsing error message", ), - payable.user, + payable.info.user, ) } else { progressAllPayments += 0.5f / payables.size @@ -308,95 +332,48 @@ class ZapPaymentHandler( } }, ) + + progressAllPayments += 0.5f / payables.size + onProgress(progressAllPayments) + + Paid(payable, true) }, - onReady = onAllDone, ) } - private fun assembleInvoice( - splitSetup: BaseZapSplitSetup, - nostrZapRequest: String?, - toUser: User?, + private suspend fun assembleInvoice( + lud16: String, + splitSetup: MyZapSplitSetup, + nostrZapRequest: LnZapRequestEvent?, zapValue: Long, message: String, - showErrorIfNoLnAddress: Boolean = true, okHttpClient: (String) -> OkHttpClient, - onError: (String, String, User?) -> Unit, onProgressStep: (percent: Float) -> Unit, context: Context, - onReady: (Payable) -> Unit, - ) { + ): Payable { var progressThisPayment = 0.00f - val lud16 = - if (splitSetup is ZapSplitSetupLnAddress) { - splitSetup.lnAddress - } else { - toUser?.info?.lnAddress() - } + val invoice = + LightningAddressResolver().lnAddressInvoice( + lnAddress = lud16, + milliSats = zapValue, + message = message, + nostrRequest = nostrZapRequest, + okHttpClient = okHttpClient, + onProgress = { + val step = it - progressThisPayment + progressThisPayment = it + onProgressStep(step) + }, + context = context, + ) - if (lud16 != null) { - LightningAddressResolver() - .lnAddressInvoice( - lnaddress = lud16, - milliSats = zapValue, - message = message, - nostrRequest = nostrZapRequest, - okHttpClient = okHttpClient, - onError = { title, msg -> - onError(title, msg, toUser) - }, - onProgress = { - val step = it - progressThisPayment - progressThisPayment = it - onProgressStep(step) - }, - context = context, - onSuccess = { - onProgressStep(1 - progressThisPayment) - onReady( - Payable( - info = splitSetup, - user = toUser, - amountMilliSats = zapValue, - invoice = it, - ), - ) - }, - ) - } else { - if (showErrorIfNoLnAddress) { - onError( - stringRes( - context, - R.string.missing_lud16, - ), - stringRes( - context, - R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats, - user?.toBestDisplayName() ?: splitSetup.mainId(), - ), - null, - ) - } - } - } + onProgressStep(1 - progressThisPayment) - private fun prepareZapRequestIfNeeded( - note: Note, - pollOption: Int?, - message: String, - zapType: LnZapEvent.ZapType, - overrideUser: User? = null, - additionalRelays: Set? = null, - onReady: (String?) -> Unit, - ) { - if (zapType != LnZapEvent.ZapType.NONZAP) { - account.createZapRequestFor(note, pollOption, message, zapType, overrideUser, additionalRelays) { zapRequest -> - onReady(zapRequest.toJson()) - } - } else { - onReady(null) - } + return Payable( + info = splitSetup, + amountMilliSats = zapValue, + invoice = invoice, + ) } } diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayBriefInfoCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CachedCashuParser.kt similarity index 60% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayBriefInfoCache.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CachedCashuParser.kt index a3de6adff8..c5a48dcc7c 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayBriefInfoCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CachedCashuParser.kt @@ -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,29 +18,23 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.ammolite.relays +package com.vitorpamplona.amethyst.service.cashu import android.util.LruCache -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter +import com.vitorpamplona.amethyst.ui.components.GenericLoadable +import kotlinx.collections.immutable.ImmutableList -object RelayBriefInfoCache { - val cache = LruCache(50) +object CachedCashuParser { + val cashuCache = LruCache>>(20) - @Immutable - class RelayBriefInfo( - val url: String, - ) { - val displayUrl: String = RelayUrlFormatter.displayUrl(url).intern() - val favIcon: String = "https://$displayUrl/favicon.ico".intern() - } + fun cached(token: String): GenericLoadable> = cashuCache[token] ?: GenericLoadable.Loading() - fun get(url: String): RelayBriefInfo { - val info = cache[url] - if (info != null) return info + fun parse(token: String): GenericLoadable> { + if (cashuCache[token] !is GenericLoadable.Loaded) { + val newCachuData = CashuParser().parse(token) + cashuCache.put(token, newCachuData) + } - val newInfo = RelayBriefInfo(url) - cache.put(url, newInfo) - return newInfo + return cashuCache[token] } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CashuParser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CashuParser.kt new file mode 100644 index 0000000000..49101b4b4a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CashuParser.kt @@ -0,0 +1,43 @@ +/** + * 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.service.cashu + +import com.vitorpamplona.amethyst.service.cashu.v3.V3Parser +import com.vitorpamplona.amethyst.service.cashu.v4.V4Parser +import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.amethyst.ui.components.GenericLoadable +import kotlinx.collections.immutable.ImmutableList + +class CashuParser { + fun parse(cashuToken: String): GenericLoadable> { + checkNotInMainThread() + + if (cashuToken.startsWith("cashuA")) { + return V3Parser.parseCashuA(cashuToken) + } + + if (cashuToken.startsWith("cashuB")) { + return V4Parser.parseCashuB(cashuToken) + } + + return GenericLoadable.Error("Could not parse this cashu token") + } +} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelaySetupInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CashuToken.kt similarity index 75% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelaySetupInfo.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CashuToken.kt index 0803ba270e..6c0eeb6d69 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelaySetupInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CashuToken.kt @@ -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,23 +18,24 @@ * 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.service.cashu import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable @Immutable -data class RelaySetupInfo( - val url: String, - val read: Boolean, - val write: Boolean, - val feedTypes: Set, +data class CashuToken( + val token: String, + val mint: String, + val totalAmount: Long, + val proofs: List, ) +@Serializable @Immutable -data class RelaySetupInfoToConnect( - val url: String, - val forceProxy: Boolean, - val read: Boolean, - val write: Boolean, - val feedTypes: Set, +class Proof( + val amount: Int, + val id: String, + val secret: String, + val C: String, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt new file mode 100644 index 0000000000..c08ec63089 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt @@ -0,0 +1,230 @@ +/** + * 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.service.cashu.melt + +import android.content.Context +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.cashu.CashuToken +import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.coroutines.executeAsync +import kotlin.coroutines.cancellation.CancellationException + +class MeltProcessor { + suspend fun melt( + token: CashuToken, + lud16: String, + okHttpClient: (String) -> OkHttpClient, + context: Context, + ): MeltResult { + val baseInvoice = + LightningAddressResolver().lnAddressInvoice( + lnAddress = lud16, + // Make invoice and leave room for fees + milliSats = token.totalAmount * 1000, + message = "Calculate Fees for Cashu", + okHttpClient = okHttpClient, + onProgress = {}, + context = context, + ) + + val fees = + feeCalculator( + mintAddress = token.mint, + invoice = baseInvoice, + okHttpClient = okHttpClient, + context = context, + ) + + val invoice = + LightningAddressResolver().lnAddressInvoice( + lnAddress = lud16, + // Make invoice and leave room for fees + milliSats = (token.totalAmount - fees) * 1000, + message = "Redeem Cashu", + okHttpClient = okHttpClient, + onProgress = {}, + context = context, + ) + + meltInvoice(token, invoice, okHttpClient, context) + + return MeltResult( + token = token, + invoice = invoice, + fees = fees, + ) + } + + suspend fun melt( + token: CashuToken, + lud16: String, + okHttpClient: (String) -> OkHttpClient, + onSuccess: (String, String) -> Unit, + onError: (String, String) -> Unit, + context: Context, + ) { + } + + suspend fun feeCalculator( + mintAddress: String, + invoice: String, + okHttpClient: (String) -> OkHttpClient, + context: Context, + ): Int = + try { + val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint + val client = okHttpClient(url) + + val factory = JsonNodeFactory.instance + + val jsonObject = factory.objectNode() + jsonObject.put("pr", invoice) + + val mediaType = "application/json; charset=utf-8".toMediaType() + val requestBody = jsonObject.toString().toRequestBody(mediaType) + val request = + Request + .Builder() + .url(url) + .post(requestBody) + .build() + + client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + val body = response.body.string() + val tree = jacksonObjectMapper().readTree(body) + + val feeCost = tree?.get("fee")?.asInt() + + if (feeCost == null) { + val msg = + tree + ?.get("detail") + ?.asText() + ?.split('.') + ?.getOrNull(0) + ?.ifBlank { null } + + throw LightningAddressResolver.LightningAddressError( + stringRes(context, R.string.cashu_failed_redemption), + if (msg != null) { + stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, msg) + } else { + stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg) + }, + ) + } + + feeCost + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + throw LightningAddressResolver.LightningAddressError( + stringRes(context, R.string.cashu_failed_redemption), + stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message), + ) + } + + private suspend fun meltInvoice( + token: CashuToken, + invoice: String, + okHttpClient: (String) -> OkHttpClient, + context: Context, + ) { + try { + val url = token.mint + "/melt" // Melt cashu tokens at Mint + val client = okHttpClient(url) + + val factory = JsonNodeFactory.instance + + val jsonObject = factory.objectNode() + + jsonObject.replace( + "proofs", + factory.arrayNode(token.proofs.size).apply { + token.proofs.forEach { + addObject().apply { + put("amount", it.amount) + put("id", it.id) + put("secret", it.secret) + put("C", it.C) + } + } + }, + ) + + jsonObject.put("pr", invoice) + + val mediaType = "application/json; charset=utf-8".toMediaType() + val requestBody = jsonObject.toString().toRequestBody(mediaType) + val request = + Request + .Builder() + .url(url) + .post(requestBody) + .build() + + client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + val body = response.body.string() + val tree = jacksonObjectMapper().readTree(body) + + val successful = tree?.get("paid")?.asText() == "true" + + if (!successful) { + val msg = + tree + ?.get("detail") + ?.asText() + ?.split('.') + ?.getOrNull(0) + ?.ifBlank { null } + + throw LightningAddressResolver.LightningAddressError( + stringRes(context, R.string.cashu_failed_redemption), + if (msg != null) { + stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, msg) + } else { + stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg) + }, + ) + } + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + throw LightningAddressResolver.LightningAddressError( + stringRes(context, R.string.cashu_successful_redemption), + stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message), + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltResult.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltResult.kt new file mode 100644 index 0000000000..11ad4711ad --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltResult.kt @@ -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.service.cashu.melt + +import com.vitorpamplona.amethyst.service.cashu.CashuToken + +class MeltResult( + val token: CashuToken, + val invoice: String, + val fees: Int, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v3/V3Parser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v3/V3Parser.kt new file mode 100644 index 0000000000..22aba4a818 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v3/V3Parser.kt @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.cashu.v3 + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.service.cashu.CashuToken +import com.vitorpamplona.amethyst.ui.components.GenericLoadable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import java.util.Base64 +import kotlin.coroutines.cancellation.CancellationException + +class V3Parser { + companion object { + fun parseCashuA(cashuToken: String): GenericLoadable> { + try { + val base64token = cashuToken.replace("cashuA", "") + val cashu = jacksonObjectMapper().readValue(String(Base64.getDecoder().decode(base64token))) + + if (cashu.token == null) { + return GenericLoadable.Error("No token found") + } + + val converted = + cashu.token.map { token -> + val proofs = token.proofs + val mint = token.mint + + var totalAmount = 0L + for (proof in proofs) { + totalAmount += proof.amount + } + + CashuToken(cashuToken, mint, totalAmount, proofs) + } + + return GenericLoadable.Loaded(converted.toImmutableList()) + } catch (e: Exception) { + if (e is CancellationException) throw e + return GenericLoadable.Error("Could not parse this cashu token") + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v3/V3Token.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v3/V3Token.kt new file mode 100644 index 0000000000..be244b2eda --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v3/V3Token.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.cashu.v3 + +import com.vitorpamplona.amethyst.service.cashu.Proof +import kotlinx.serialization.Serializable + +@Serializable +class V3Token( + val unit: String?, + val memo: String?, + val token: List?, +) + +@Serializable +class V3T( + val mint: String, + val proofs: List, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Models.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Models.kt new file mode 100644 index 0000000000..071b4c7d7e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Models.kt @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.cashu.v4 + +import kotlinx.serialization.Serializable +import kotlinx.serialization.cbor.ByteString + +@Serializable +class V4Token( + // mint + val m: String, + // unit + val u: String, + // memo + val d: String? = null, + val t: Array?, +) + +@Serializable +class V4T( + // identifier + @ByteString + val i: ByteArray, + val p: Array, +) + +@Serializable +class V4Proof( + // amount + val a: Int, + // secret + val s: String, + // signature + @ByteString + val c: ByteArray, + // no idea what this is + val d: V4DleqProof? = null, + // witness + val w: String? = null, +) + +@Serializable +class V4DleqProof( + @ByteString + val e: ByteArray, + @ByteString + val s: ByteArray, + @ByteString + val r: ByteArray, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Parser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Parser.kt new file mode 100644 index 0000000000..d26f54060c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Parser.kt @@ -0,0 +1,73 @@ +/** + * 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.service.cashu.v4 + +import com.vitorpamplona.amethyst.service.cashu.CashuToken +import com.vitorpamplona.amethyst.service.cashu.Proof +import com.vitorpamplona.amethyst.ui.components.GenericLoadable +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.cbor.Cbor +import kotlinx.serialization.decodeFromByteArray +import java.util.Base64 +import kotlin.coroutines.cancellation.CancellationException + +class V4Parser { + companion object { + @OptIn(ExperimentalSerializationApi::class) + fun parseCashuB(cashuToken: String): GenericLoadable> { + try { + val base64token = cashuToken.replace("cashuB", "") + val parser = Cbor { ignoreUnknownKeys = true } + val v4Token = parser.decodeFromByteArray(Base64.getUrlDecoder().decode(base64token)) + val v4proofs = v4Token.t ?: return GenericLoadable.Error("No token found") + + val converted = + v4proofs.map { id -> + val proofs = + id.p.map { + Proof( + it.a, + id.i.toHexKey(), + it.s, + it.c.toHexKey(), + ) + } + val mint = v4Token.m + + var totalAmount = 0L + for (proof in proofs) { + totalAmount += proof.amount + } + + CashuToken(cashuToken, mint, totalAmount, proofs) + } + + return GenericLoadable.Loaded(converted.toImmutableList()) + } catch (e: Exception) { + if (e is CancellationException) throw e + return GenericLoadable.Error("Could not parse this cashu token") + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityFlow.kt index 3b120bcedd..ff69703b43 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityFlow.kt @@ -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,7 +25,6 @@ import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities import android.util.Log -import com.vitorpamplona.ammolite.service.checkNotInMainThread import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.awaitClose @@ -49,7 +48,6 @@ class ConnectivityFlow( object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { super.onAvailable(network) - checkNotInMainThread() Log.d("ConnectivityFlow", "onAvailable ${network.networkHandle}") connectivityManager.getNetworkCapabilities(network)?.let { trySend(ConnectivityStatus.Active(network.networkHandle, it.isMeteredOrMobileData())) @@ -61,7 +59,6 @@ class ConnectivityFlow( networkCapabilities: NetworkCapabilities, ) { super.onCapabilitiesChanged(network, networkCapabilities) - checkNotInMainThread() val isMobile = networkCapabilities.isMeteredOrMobileData() Log.d("ConnectivityFlow", "onCapabilitiesChanged ${network.networkHandle} $isMobile") trySend(ConnectivityStatus.Active(network.networkHandle, isMobile)) @@ -77,7 +74,6 @@ class ConnectivityFlow( } awaitClose { - checkNotInMainThread() Log.d("ConnectivityFlow", "Stopping Connectivity Flow") connectivityManager.unregisterNetworkCallback(networkCallback) trySend(ConnectivityStatus.Off) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityManager.kt index a9b49c7bb1..e6eb1cc85c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityManager.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityStatus.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityStatus.kt index 017d013612..998d6e859a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityStatus.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityStatus.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt index be8d891e8c..6d31d5578e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt @@ -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,38 +21,45 @@ package com.vitorpamplona.amethyst.service.eventCache import android.util.Log -import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.NostrChatroomDataSource import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import java.util.concurrent.atomic.AtomicBoolean -class MemoryTrimmingService { +class MemoryTrimmingService( + val cache: LocalCache, +) { var isTrimmingMemoryMutex = AtomicBoolean(false) - private suspend fun doTrim(account: Account?) { - LocalCache.cleanObservers() - - val accounts = LocalPreferences.allSavedAccounts().mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet() + private suspend fun doTrim( + account: Account? = null, + otherAccounts: List, + ) { + cache.cleanMemory() + cache.cleanObservers() account?.let { - LocalCache.pruneHiddenMessages(it) - LocalCache.pruneOldAndHiddenMessages(it) - NostrChatroomDataSource.clearEOSEs(it) - - LocalCache.pruneContactLists(accounts) - LocalCache.pruneRepliesAndReactions(accounts) - LocalCache.prunePastVersionsOfReplaceables() - LocalCache.pruneExpiredEvents() + cache.pruneHiddenEvents(it) + cache.pruneHiddenMessages(it) } + + val accounts = otherAccounts.mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet() + cache.pruneOldMessages() + cache.pruneContactLists(accounts) + cache.pruneRepliesAndReactions(accounts) + cache.prunePastVersionsOfReplaceables() + cache.pruneExpiredEvents() } - suspend fun run(account: Account?) { + suspend fun run( + account: Account?, + otherAccounts: List, + ) { if (isTrimmingMemoryMutex.compareAndSet(false, true)) { Log.d("ServiceManager", "Trimming Memory") try { - doTrim(account) + doTrim(account, otherAccounts) } finally { isTrimmingMemoryMutex.getAndSet(false) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/Base64Fetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/Base64Fetcher.kt index ab9c738621..50b1621bfb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/Base64Fetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/Base64Fetcher.kt @@ -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 @@ -39,12 +39,14 @@ class Base64Fetcher( private val options: Options, private val data: Uri, ) : Fetcher { - override suspend fun fetch(): FetchResult = - ImageFetchResult( - image = Base64Image.Companion.toBitmap(data.toString()).asImage(true), - isSampled = false, - dataSource = DataSource.MEMORY, - ) + override suspend fun fetch(): FetchResult? = + runCatching { + ImageFetchResult( + image = Base64Image.Companion.toBitmap(data.toString()).asImage(true), + isSampled = false, + dataSource = DataSource.MEMORY, + ) + }.getOrNull() object Factory : Fetcher.Factory { override fun create( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlurHashFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlurHashFetcher.kt index f0b6cad849..c755f8d14e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlurHashFetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlurHashFetcher.kt @@ -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 @@ -40,10 +40,10 @@ class BlurHashFetcher( private val options: Options, private val data: BlurhashWrapper, ) : Fetcher { - override suspend fun fetch(): FetchResult { + override suspend fun fetch(): FetchResult? { val hash = data.blurhash - val bitmap = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: throw Exception("Unable to convert Blurhash $data") + val bitmap = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: return null return ImageFetchResult( image = bitmap.asImage(true), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageCacheFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageCacheFactory.kt index c95c6301fe..3fc58d02c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageCacheFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageCacheFactory.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt index 6abadd5c7a..b04fa71fce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt @@ -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,24 +24,43 @@ import android.app.Application import android.os.Build import coil3.ImageLoader import coil3.SingletonImageLoader +import coil3.Uri +import coil3.annotation.DelicateCoilApi +import coil3.annotation.ExperimentalCoilApi import coil3.disk.DiskCache +import coil3.fetch.Fetcher import coil3.gif.AnimatedImageDecoder import coil3.gif.GifDecoder import coil3.memory.MemoryCache -import coil3.network.okhttp.OkHttpNetworkFetcherFactory +import coil3.network.CacheStrategy +import coil3.network.ConnectivityChecker +import coil3.network.NetworkFetcher +import coil3.network.okhttp.asNetworkClient +import coil3.request.Options import coil3.size.Precision import coil3.svg.SvgDecoder import coil3.util.DebugLogger +import com.vitorpamplona.amethyst.isDebug import okhttp3.Call class ImageLoaderSetup { companion object { + val gifFactory = + if (Build.VERSION.SDK_INT >= 28) { + AnimatedImageDecoder.Factory() + } else { + GifDecoder.Factory() + } + val svgFactory = SvgDecoder.Factory() + + val debugLogger = if (isDebug) DebugLogger() else null + + @OptIn(DelicateCoilApi::class) fun setup( app: Application, diskCache: DiskCache, memoryCache: MemoryCache, - isDebug: Boolean, - callFactory: () -> Call.Factory, + callFactory: (url: String) -> Call.Factory, ) { SingletonImageLoader.setUnsafe( ImageLoader @@ -49,21 +68,81 @@ class ImageLoaderSetup { .diskCache { diskCache } .memoryCache { memoryCache } .precision(Precision.INEXACT) - .logger(if (isDebug) DebugLogger() else null) + .logger(debugLogger) .components { - if (Build.VERSION.SDK_INT >= 28) { - add(AnimatedImageDecoder.Factory()) - } else { - add(GifDecoder.Factory()) - } - add(SvgDecoder.Factory()) + add(gifFactory) + add(svgFactory) add(Base64Fetcher.Factory) add(BlurHashFetcher.Factory) add(Base64Fetcher.BKeyer) add(BlurHashFetcher.BKeyer) - add(OkHttpNetworkFetcherFactory(callFactory)) + add(OkHttpFactory(callFactory)) }.build(), ) } } } + +/** + * Copied from Coil to allow networkClient to be a function of the url. + * So that Tor and non Tor clients can be used. + */ +@OptIn(ExperimentalCoilApi::class) +class OkHttpFactory( + val networkClient: (url: String) -> Call.Factory, +) : Fetcher.Factory { + private val cacheStrategyLazy = lazy { CacheStrategy.DEFAULT } + private val connectivityCheckerLazy = singleParameterLazy(::ConnectivityChecker) + + override fun create( + data: Uri, + options: Options, + imageLoader: ImageLoader, + ): Fetcher? { + if (!isApplicable(data)) return null + + val url = data.toString() + + return NetworkFetcher( + url = url, + options = options, + networkClient = lazy { networkClient(url).asNetworkClient() }, + diskCache = lazy { imageLoader.diskCache }, + cacheStrategy = cacheStrategyLazy, + connectivityChecker = connectivityCheckerLazy.get(options.context), + ) + } + + private fun isApplicable(data: Uri): Boolean = data.scheme == "http" || data.scheme == "https" +} + +internal fun singleParameterLazy(initializer: (P) -> T) = SingleParameterLazy(initializer) + +internal class SingleParameterLazy( + initializer: (P) -> T, +) : Any() { + private var initializer: ((P) -> T)? = initializer + private var value: Any? = UNINITIALIZED + + @Suppress("UNCHECKED_CAST") + fun get(parameter: P): T { + val value1 = value + if (value1 !== UNINITIALIZED) { + return value1 as T + } + + return synchronized(this) { + val value2 = value + if (value2 !== UNINITIALIZED) { + value2 as T + } else { + val newValue = initializer!!(parameter) + value = newValue + initializer = null + newValue + } + } + } +} + +private object UNINITIALIZED diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/CachedLnInvoice.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/CachedLnInvoice.kt index ee4f1ac9e6..681cd1bcac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/CachedLnInvoice.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/CachedLnInvoice.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt index 44d5c09102..42cc045fa0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt @@ -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,142 +23,137 @@ package com.vitorpamplona.amethyst.service.lnurl import android.content.Context import android.util.Log import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.HttpStatusMessages -import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.lightning.Lud06 +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response +import okhttp3.coroutines.executeAsync import java.math.BigDecimal import java.math.RoundingMode import java.net.URLEncoder import kotlin.coroutines.cancellation.CancellationException class LightningAddressResolver { - fun assembleUrl(lnaddress: String): String? { - val parts = lnaddress.split("@") + fun assembleUrl(lnAddress: String): String? { + val parts = lnAddress.split("@") if (parts.size == 2) { return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}" } - if (lnaddress.lowercase().startsWith("lnurl")) { - return Lud06().toLnUrlp(lnaddress) + if (lnAddress.lowercase().startsWith("lnurl")) { + return Lud06().toLnUrlp(lnAddress) } return null } - private fun fetchLightningAddressJson( - lnaddress: String, - okttpClient: (String) -> OkHttpClient, - onSuccess: (String) -> Unit, - onError: (String, String) -> Unit, - context: Context, - ) { - checkNotInMainThread() + class LightningAddressError( + val title: String, + val msg: String, + ) : Exception(msg) - val url = assembleUrl(lnaddress) + private suspend fun fetchLightningAddressJson( + lnAddress: String, + okHttpClient: (String) -> OkHttpClient, + context: Context, + ): String { + val url = assembleUrl(lnAddress) if (url == null) { - onError( + throw LightningAddressError( stringRes(context, R.string.error_unable_to_fetch_invoice), - stringRes( - context, - R.string.could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup, - lnaddress, - ), + stringRes(context, R.string.could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup, lnAddress), ) - return } - val client = okttpClient(url) + val client = okHttpClient(url) - try { + return try { val request: Request = Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(url) .build() - client.newCall(request).execute().use { - if (it.isSuccessful) { - onSuccess(it.body.string()) - } else { - onError( - stringRes(context, R.string.error_unable_to_fetch_invoice), - stringRes( - context, - R.string - .the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct, - url, - lnaddress, - errorMessage(it, context), - ), - ) + client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + if (response.isSuccessful) { + response.body.string() + } else { + throw LightningAddressError( + stringRes(context, R.string.error_unable_to_fetch_invoice), + stringRes( + context, + R.string + .the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct, + url, + lnAddress, + errorMessage(response, context), + ), + ) + } } } } catch (e: Exception) { if (e is CancellationException) throw e - e.printStackTrace() - onError( + throw LightningAddressError( stringRes(context, R.string.error_unable_to_fetch_invoice), stringRes( context, R.string .could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct_exception, url, - lnaddress, + lnAddress, e.suppressedExceptions.getOrNull(0)?.message ?: e.cause?.message ?: e.message, ), ) } } - fun fetchLightningInvoice( + suspend fun fetchLightningInvoice( lnCallback: String, milliSats: Long, message: String, - nostrRequest: String? = null, - okttpClient: (String) -> OkHttpClient, - onSuccess: (String) -> Unit, - onError: (String, String) -> Unit, + nostrRequest: LnZapRequestEvent? = null, + okHttpClient: (String) -> OkHttpClient, context: Context, - ) { - checkNotInMainThread() - + ): String { val encodedMessage = URLEncoder.encode(message, "utf-8") val urlBinder = if (lnCallback.contains("?")) "&" else "?" var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage" if (nostrRequest != null) { - val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8") + val encodedNostrRequest = URLEncoder.encode(nostrRequest.toJson(), "utf-8") url += "&nostr=$encodedNostrRequest" } - val client = okttpClient(url) + val client = okHttpClient(url) val request: Request = Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(url) .build() - client.newCall(request).execute().use { - if (it.isSuccessful) { - onSuccess(it.body.string()) - } else { - onError( - stringRes(context, R.string.error_unable_to_fetch_invoice), - stringRes(context, R.string.could_not_fetch_invoice_from_details, lnCallback, errorMessage(it, context)), - ) + return client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + if (response.isSuccessful) { + response.body.string() + } else { + throw LightningAddressError( + stringRes(context, R.string.error_unable_to_fetch_invoice), + stringRes(context, R.string.could_not_fetch_invoice_from_details, lnCallback, errorMessage(response, context)), + ) + } } } } @@ -178,7 +173,7 @@ class LightningAddressResolver { val statusNode = tree.get("status") if (tree.get("error").isBoolean && messageNode != null) { - if (errorNode.asBoolean() == true) { + if (errorNode.asBoolean()) { return messageNode.asText() } } @@ -203,146 +198,136 @@ class LightningAddressResolver { ?: response.code.toString() } - fun lnAddressInvoice( - lnaddress: String, + suspend fun lnAddressInvoice( + lnAddress: String, milliSats: Long, message: String, - nostrRequest: String? = null, + nostrRequest: LnZapRequestEvent? = null, okHttpClient: (String) -> OkHttpClient, - onSuccess: (String) -> Unit, - onError: (String, String) -> Unit, onProgress: (percent: Float) -> Unit, context: Context, - ) { + ): String { val mapper = jacksonObjectMapper() - fetchLightningAddressJson( - lnaddress, - okHttpClient, - onSuccess = { lnAddressJson -> - onProgress(0.4f) + val lnAddressJson = + fetchLightningAddressJson( + lnAddress, + okHttpClient, + context, + ) - val lnurlp = - try { - mapper.readTree(lnAddressJson) - } catch (t: Throwable) { - if (t is CancellationException) throw t - onError( - stringRes(context, R.string.error_unable_to_fetch_invoice), - stringRes( - context, - R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup_with_user, - lnaddress, - ), - ) - null - } + onProgress(0.4f) - val callback = lnurlp?.get("callback")?.asText()?.ifBlank { null } - - if (callback == null) { - onError( - stringRes(context, R.string.error_unable_to_fetch_invoice), - stringRes( - context, - R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user, - lnaddress, - ), - ) - } - - val allowsNostr = lnurlp?.get("allowsNostr")?.asBoolean() ?: false - - callback?.let { cb -> - fetchLightningInvoice( - cb, - milliSats, - message, - if (allowsNostr) nostrRequest else null, - okHttpClient, - onSuccess = { - onProgress(0.6f) - - val lnInvoice = - try { - mapper.readTree(it) - } catch (t: Throwable) { - if (t is CancellationException) throw t - onError( - stringRes(context, R.string.error_unable_to_fetch_invoice), - stringRes( - context, - R.string - .error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user, - lnaddress, - ), - ) - null - } - - lnInvoice - ?.get("pr") - ?.asText() - ?.ifBlank { null } - ?.let { pr -> - // Forces LN Invoice amount to be the requested amount. - val expectedAmountInSats = - BigDecimal(milliSats).divide(BigDecimal(1000), RoundingMode.HALF_UP).toLong() - val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr) - if (invoiceAmount.toLong() == expectedAmountInSats) { - onProgress(0.7f) - onSuccess(pr) - } else { - onProgress(0.0f) - onError( - stringRes(context, R.string.error_unable_to_fetch_invoice), - stringRes( - context, - R.string.incorrect_invoice_amount_sats_from_it_should_have_been, - invoiceAmount.toLong().toString(), - lnaddress, - expectedAmountInSats.toString(), - ), - ) - } - } - ?: lnInvoice - ?.get("reason") - ?.asText() - ?.ifBlank { null } - ?.let { reason -> - onProgress(0.0f) - onError( - stringRes(context, R.string.error_unable_to_fetch_invoice), - stringRes( - context, - R.string - .unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user, - lnaddress, - reason, - ), - ) - } - ?: run { - onProgress(0.0f) - onError( - stringRes(context, R.string.error_unable_to_fetch_invoice), - stringRes( - context, - R.string - .unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user, - lnaddress, - ), - ) - } - }, - onError = onError, + val lnurlp = + try { + mapper.readTree(lnAddressJson) + } catch (t: Throwable) { + if (t is CancellationException) throw t + throw LightningAddressError( + stringRes(context, R.string.error_unable_to_fetch_invoice), + stringRes( context, - ) - } - }, - onError = onError, - context, - ) + R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup_with_user, + lnAddress, + ), + ) + } + + val callbackUrl = lnurlp?.get("callback")?.asText()?.ifBlank { null } + + if (callbackUrl == null) { + throw LightningAddressError( + stringRes(context, R.string.error_unable_to_fetch_invoice), + stringRes( + context, + R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user, + lnAddress, + ), + ) + } + + val allowsNostr = lnurlp.get("allowsNostr")?.asBoolean() ?: false + + val invoice = + fetchLightningInvoice( + lnCallback = callbackUrl, + milliSats = milliSats, + message = message, + nostrRequest = if (allowsNostr) nostrRequest else null, + okHttpClient = okHttpClient, + context = context, + ) + + onProgress(0.6f) + + val lnInvoice = + try { + mapper.readTree(invoice) + } catch (t: Throwable) { + if (t is CancellationException) throw t + throw LightningAddressError( + stringRes(context, R.string.error_unable_to_fetch_invoice), + stringRes( + context, + R.string + .error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user, + lnAddress, + ), + ) + } + + val pr = lnInvoice?.get("pr")?.asText()?.ifBlank { null } + + if (pr == null) { + onProgress(0.0f) + val reason = lnInvoice?.get("reason")?.asText()?.ifBlank { null } + + if (reason != null) { + throw LightningAddressError( + stringRes(context, R.string.error_unable_to_fetch_invoice), + stringRes( + context, + R.string + .unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user, + lnAddress, + reason, + ), + ) + } else { + throw LightningAddressError( + stringRes(context, R.string.error_unable_to_fetch_invoice), + stringRes( + context, + R.string + .unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user, + lnAddress, + ), + ) + } + } + + // Forces LN Invoice amount to be the requested amount. + val expectedAmountInSats = + BigDecimal(milliSats).divide(BigDecimal(1000), RoundingMode.HALF_UP).toLong() + + val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr) + + if (invoiceAmount.toLong() != expectedAmountInSats) { + onProgress(0.0f) + throw LightningAddressError( + stringRes(context, R.string.error_unable_to_fetch_invoice), + stringRes( + context, + R.string.incorrect_invoice_amount_sats_from_it_should_have_been, + invoiceAmount.toLong().toString(), + lnAddress, + expectedAmountInSats.toString(), + ), + ) + } + + onProgress(0.7f) + + return pr } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/AddressExt.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/AddressExt.kt index 89c5b836e5..d5ef54a369 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/AddressExt.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/AddressExt.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/CachedReversedGeoLocations.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/CachedReversedGeoLocations.kt index 34e62f9791..44674d0b98 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/CachedReversedGeoLocations.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/CachedReversedGeoLocations.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt index 22aa275aeb..b1efde02d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt index 18573b4f11..a9ae46535a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt @@ -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,7 +21,7 @@ package com.vitorpamplona.amethyst.service.location import android.content.Context -import coil3.util.CoilUtils.result +import android.util.Log import com.fonfon.kgeohash.GeoHash import com.fonfon.kgeohash.toGeoHash import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeohashPrecision @@ -78,7 +78,7 @@ class LocationState( }.onEach { latestLocation = it }.catch { e -> - e.printStackTrace() + Log.w("GeohashStateFlow", "Exception in the flow", e) latestLocation = LocationResult.LackPermission emit(LocationResult.LackPermission) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/ReverseGeolocation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/ReverseGeolocation.kt index a5ee5d5ff1..27e0c3120a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/ReverseGeolocation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/ReverseGeolocation.kt @@ -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 @@ -84,6 +84,7 @@ class ReverseGeolocation { 1, ) } catch (e: IOException) { + Log.w("ReverseGeolocation", "IO Error", e) e.printStackTrace() return null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/ChoreographerHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/ChoreographerHelper.kt index 127fae2f0d..2acbb3119b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/ChoreographerHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/ChoreographerHelper.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/LogMonitor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/LogMonitor.kt index c23f43c2a2..1d35148f72 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/LogMonitor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/LogMonitor.kt @@ -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,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.logging +import android.annotation.SuppressLint import android.os.Handler import android.os.HandlerThread import android.os.Looper @@ -164,6 +165,8 @@ class StackSampler( companion object { const val SEPARATOR: String = "\r\n" + + @SuppressLint("SimpleDateFormat") val TIME_FORMATTER: SimpleDateFormat = SimpleDateFormat("MM-dd HH:mm:ss.SSS") } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/Logging.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/Logging.kt index 22a0dde20f..96f257d850 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/Logging.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/Logging.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 0d662a63d6..625c6cf2d2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -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,6 @@ import android.app.NotificationManager import android.content.Context import android.util.Log import androidx.core.content.ContextCompat -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.AccountSettings @@ -43,8 +42,6 @@ import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEven import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri -import com.vitorpamplona.quartz.nip37Drafts.DraftEvent -import com.vitorpamplona.quartz.nip55AndroidSigner.NostrSignerExternal import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent @@ -62,7 +59,6 @@ class EventNotificationConsumer( suspend fun consume(event: GiftWrapEvent) { Log.d(TAG, "New Notification Arrived") - if (!LocalCache.justVerify(event)) return // PushNotification Wraps don't include a receiver. // Test with all logged in accounts @@ -87,47 +83,32 @@ class EventNotificationConsumer( pushWrappedEvent: GiftWrapEvent, account: AccountSettings, ) { - // TODO: Modify the external launcher to launch as different users. - // Right now it only registers if Amber has already approved this signature - val signer = account.createSigner() - if (signer is NostrSignerExternal) { - signer.launcher.registerLauncher( - launcher = { }, - contentResolver = Amethyst.instance::contentResolverFn, - ) - } + val signer = account.createSigner(applicationContext.contentResolver) - pushWrappedEvent.unwrapThrowing(signer) { notificationEvent -> - consumeNotificationEvent(notificationEvent, signer, account) - } + val notificationEvent = pushWrappedEvent.unwrapThrowing(signer) + consumeNotificationEvent(notificationEvent, signer, account) } - fun consumeNotificationEvent( + suspend fun consumeNotificationEvent( notificationEvent: Event, signer: NostrSigner, account: AccountSettings, ) { val consumed = LocalCache.hasConsumed(notificationEvent) - val verified = LocalCache.justVerify(notificationEvent) - Log.d(TAG, "New Notification ${notificationEvent.kind} ${notificationEvent.id} Arrived for ${signer.pubKey} consumed= $consumed && verified= $verified") - if (!consumed && verified) { + Log.d(TAG, "New Notification ${notificationEvent.kind} ${notificationEvent.id} Arrived for ${signer.pubKey} consumed= $consumed") + if (!consumed) { Log.d(TAG, "New Notification was verified") - unwrapAndConsume(notificationEvent, signer) { innerEvent -> - if (!notificationManager().areNotificationsEnabled()) return@unwrapAndConsume + if (!notificationManager().areNotificationsEnabled()) return + Log.d(TAG, "Notifications are enabled") - Log.d(TAG, "Unwrapped consume $consumed ${innerEvent.javaClass.simpleName}") - if (innerEvent is PrivateDmEvent) { - Log.d(TAG, "New Nip-04 DM to Notify") - notify(innerEvent, signer, account) - } else if (innerEvent is LnZapEvent) { - Log.d(TAG, "New Zap to Notify") - notify(innerEvent, signer, account) - } else if (innerEvent is ChatMessageEvent) { - Log.d(TAG, "New ChatMessage to Notify") - notify(innerEvent, signer, account) - } else if (innerEvent is ChatMessageEncryptedFileHeaderEvent) { - Log.d(TAG, "New ChatMessage File to Notify") - notify(innerEvent, signer, account) + unwrapAndConsume(notificationEvent, signer)?.let { innerEvent -> + Log.d(TAG, "Unwrapped consume ${innerEvent.javaClass.simpleName}") + + when (innerEvent) { + is PrivateDmEvent -> notify(innerEvent, signer, account) + is LnZapEvent -> notify(innerEvent, signer, account) + is ChatMessageEvent -> notify(innerEvent, signer, account) + is ChatMessageEncryptedFileHeaderEvent -> notify(innerEvent, signer, account) } } } @@ -135,8 +116,6 @@ class EventNotificationConsumer( suspend fun findAccountAndConsume(event: Event) { Log.d(TAG, "New Notification Arrived") - if (!LocalCache.justVerify(event)) return - val users = event.taggedUserIds().map { LocalCache.getOrCreateUser(it) } val npubs = users.map { it.pubkeyNpub() }.toSet() @@ -148,16 +127,7 @@ class EventNotificationConsumer( LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)?.let { acc -> Log.d(TAG, "New Notification Testing if for ${it.npub}") try { - // TODO: Modify the external launcher to launch as different users. - // Right now it only registers if Amber has already approved this signature - val signer = acc.createSigner() - if (signer is NostrSignerExternal) { - signer.launcher.registerLauncher( - launcher = { }, - contentResolver = Amethyst.instance::contentResolverFn, - ) - } - + val signer = acc.createSigner(applicationContext.contentResolver) consumeNotificationEvent(event, signer, acc) matchAccount = true } catch (e: Exception) { @@ -169,34 +139,45 @@ class EventNotificationConsumer( } } - private fun unwrapAndConsume( + private suspend fun unwrapAndConsume( event: Event, signer: NostrSigner, - onReady: (Event) -> Unit, - ) { - if (!LocalCache.justVerify(event)) return - if (LocalCache.hasConsumed(event)) return + ): Event? { + if (LocalCache.hasConsumed(event)) return null - when (event) { + return when (event) { is GiftWrapEvent -> { - event.unwrap(signer) { - unwrapAndConsume(it, signer, onReady) - LocalCache.justConsume(event, null) + if (LocalCache.justConsume(event, null, false)) { + // new event + val inner = event.unwrapThrowing(signer) + // clear the encrypted payload to save memory + LocalCache.getOrCreateNote(event.id).event = event.copyNoContent() + + unwrapAndConsume(inner, signer) + } else { + null } } is SealedRumorEvent -> { - event.unseal(signer) { - if (!LocalCache.hasConsumed(it)) { - // this is not verifiable - LocalCache.justConsume(it, null) - onReady(it) + if (LocalCache.justConsume(event, null, false)) { + // new event + val inner = event.unsealThrowing(signer) + // clear the encrypted payload to save memory + LocalCache.getOrCreateNote(event.id).event = event.copyNoContent() + + // this is not verifiable + if (LocalCache.justConsume(inner, null, true)) { + event + } else { + null } - LocalCache.justConsume(event, null) + } else { + null } } else -> { - LocalCache.justConsume(event, null) - onReady(event) + LocalCache.justConsume(event, null, false) + event } } } @@ -206,6 +187,7 @@ class EventNotificationConsumer( signer: NostrSigner, acc: AccountSettings, ) { + Log.d(TAG, "New ChatMessage File to Notify") if ( // old event being re-broadcasted event.createdAt > TimeUtils.fifteenMinutesAgo() && @@ -213,7 +195,7 @@ class EventNotificationConsumer( event.pubKey != signer.pubKey ) { // from the user Log.d(TAG, "Notifying") - val myUser = LocalCache.getUserIfExists(signer.pubKey) ?: return + val chatroomList = LocalCache.getOrCreateChatroomList(signer.pubKey) val chatNote = LocalCache.getNoteIfExists(event.id) ?: return val chatRoom = event.chatroomKey(signer.pubKey) @@ -221,8 +203,7 @@ class EventNotificationConsumer( val isKnownRoom = ( - myUser.privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true || - myUser.hasSentMessagesTo(chatRoom) + chatroomList.rooms.get(chatRoom)?.senderIntersects(followingKeySet) == true || chatroomList.hasSentMessagesTo(chatRoom) ) if (isKnownRoom) { @@ -251,6 +232,7 @@ class EventNotificationConsumer( signer: NostrSigner, acc: AccountSettings, ) { + Log.d(TAG, "New ChatMessage to Notify") if ( // old event being re-broadcasted event.createdAt > TimeUtils.fifteenMinutesAgo() && @@ -258,17 +240,13 @@ class EventNotificationConsumer( event.pubKey != signer.pubKey ) { // from the user Log.d(TAG, "Notifying") - val myUser = LocalCache.getUserIfExists(signer.pubKey) ?: return + val chatroomList = LocalCache.getOrCreateChatroomList(signer.pubKey) val chatNote = LocalCache.getNoteIfExists(event.id) ?: return val chatRoom = event.chatroomKey(signer.pubKey) val followingKeySet = acc.backupContactList?.unverifiedFollowKeySet()?.toSet() ?: return - val isKnownRoom = - ( - myUser.privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true || - myUser.hasSentMessagesTo(chatRoom) - ) + val isKnownRoom = chatroomList.rooms.get(chatRoom)?.senderIntersects(followingKeySet) == true || chatroomList.hasSentMessagesTo(chatRoom) if (isKnownRoom) { val content = chatNote.event?.content ?: "" @@ -289,13 +267,14 @@ class EventNotificationConsumer( } } - private fun notify( + private suspend fun notify( event: PrivateDmEvent, signer: NostrSigner, acc: AccountSettings, ) { + Log.d(TAG, "New Nip-04 DM to Notify") val note = LocalCache.getNoteIfExists(event.id) ?: return - val myUser = LocalCache.getUserIfExists(signer.pubKey) ?: return + val chatroomList = LocalCache.getOrCreateChatroomList(signer.pubKey) // old event being re-broadcast if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return @@ -305,13 +284,11 @@ class EventNotificationConsumer( val chatRoom = event.chatroomKey(signer.pubKey) - val isKnownRoom = - myUser.privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true || - myUser.hasSentMessagesTo(chatRoom) + val isKnownRoom = chatroomList.rooms.get(chatRoom)?.senderIntersects(followingKeySet) == true || chatroomList.hasSentMessagesTo(chatRoom) if (isKnownRoom) { note.author?.let { - decryptContent(note, signer) { content -> + decryptContent(note, signer)?.let { content -> val user = note.author?.toBestDisplayName() ?: "" val userPicture = note.author?.profilePicture() val noteUri = note.toNEvent() + "?account=" + acc.keyPair.pubKey.toNpub() @@ -323,47 +300,44 @@ class EventNotificationConsumer( } } - fun decryptZapContentAuthor( - note: Note, + suspend fun decryptZapContentAuthor( + event: LnZapRequestEvent, signer: NostrSigner, - onReady: (Event) -> Unit, - ) { - val event = note.event - if (event is LnZapRequestEvent) { - if (event.isPrivateZap()) { - event.decryptPrivateZap(signer) { onReady(it) } - } else { - onReady(event) - } - } - } - - fun decryptContent( - note: Note, - signer: NostrSigner, - onReady: (String) -> Unit, - ) { - val event = note.event - if (event is PrivateDmEvent) { - event.plainContent(signer, onReady) - } else if (event is LnZapRequestEvent) { - decryptZapContentAuthor(note, signer) { onReady(it.content) } - } else if (event is DraftEvent) { - event.cachedDraft(signer) { - onReady(it.content) - } + ): Event? = + if (event.isPrivateZap() && event.zappedAuthor().contains(event.pubKey)) { + signer.decryptZapEvent(event) } else { - event?.content?.let { onReady(it) } + event + } + + suspend fun decryptContent( + note: Note, + signer: NostrSigner, + ): String? { + val event = note.event + when (event) { + is PrivateDmEvent -> { + return event.decryptContent(signer) + } + + is LnZapRequestEvent -> { + return decryptZapContentAuthor(event, signer)?.content + } + + else -> { + return event?.content + } } } - private fun notify( + private suspend fun notify( event: LnZapEvent, signer: NostrSigner, acc: AccountSettings, ) { + Log.d(TAG, "New Zap to Notify") Log.d(TAG, "Notify Start ${event.toNostrUri()}") - val noteZapEvent = LocalCache.getNoteIfExists(event.id) ?: return + LocalCache.getNoteIfExists(event.id) ?: return Log.d(TAG, "Notify Not Notified Yet") @@ -373,8 +347,7 @@ class EventNotificationConsumer( Log.d(TAG, "Notify Not an old event") val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return - val noteZapped = - event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return + val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return Log.d(TAG, "Notify ZapRequest $noteZapRequest zapped $noteZapped") @@ -388,17 +361,17 @@ class EventNotificationConsumer( Log.d(TAG, "Notify Amount $amount") (noteZapRequest.event as? LnZapRequestEvent)?.let { event -> - decryptZapContentAuthor(noteZapRequest, signer) { + decryptZapContentAuthor(event, signer)?.let { decryptedEvent -> Log.d(TAG, "Notify Decrypted if Private Zap ${event.id}") - val author = LocalCache.getOrCreateUser(it.pubKey) - val senderInfo = Pair(author, it.content.ifBlank { null }) + val author = LocalCache.getOrCreateUser(decryptedEvent.pubKey) + val senderInfo = Pair(author, decryptedEvent.content.ifBlank { null }) if (noteZapped.event?.content != null) { - decryptContent(noteZapped, signer) { + decryptContent(noteZapped, signer)?.let { decrypted -> Log.d(TAG, "Notify Decrypted if Private Note") - val zappedContent = it.split("\n").get(0) + val zappedContent = decrypted.split("\n")[0] val user = senderInfo.first.toBestDisplayName() var title = stringRes(applicationContext, R.string.app_notification_zaps_channel_message, amount) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index 016ca7817f..1938019e48 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -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,9 +26,9 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.graphics.drawable.BitmapDrawable -import android.net.Uri import android.service.notification.StatusBarNotification import androidx.core.app.NotificationCompat +import androidx.core.net.toUri import coil3.ImageLoader import coil3.asDrawable import coil3.executeBlocking @@ -201,7 +201,7 @@ object NotificationUtils { } val contentIntent = - Intent(applicationContext, MainActivity::class.java).apply { data = Uri.parse(uri) } + Intent(applicationContext, MainActivity::class.java).apply { data = uri.toUri() } val contentPendingIntent = PendingIntent.getActivity( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/PokeyReceiver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/PokeyReceiver.kt index abdddba9e7..688c425157 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/PokeyReceiver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/PokeyReceiver.kt @@ -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,9 +27,7 @@ import android.content.Context.RECEIVER_EXPORTED import android.content.Intent import android.content.IntentFilter import android.os.Build -import android.provider.LiveFolders.INTENT import android.util.Log -import androidx.core.content.ContextCompat.registerReceiver import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.quartz.nip01Core.core.Event import kotlinx.coroutines.launch diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt index 59423fa84d..43c2279b51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt @@ -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,23 +25,24 @@ import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.LocalPreferences -import com.vitorpamplona.amethyst.launchAndWaitAll +import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.model.AccountSettings -import com.vitorpamplona.amethyst.tryAndWait +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent -import com.vitorpamplona.quartz.nip55AndroidSigner.NostrSignerExternal +import com.vitorpamplona.quartz.utils.mapNotNullAsync import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody -import kotlin.coroutines.resume +import okhttp3.coroutines.executeAsync class RegisterAccounts( private val accounts: List, private val client: (String) -> OkHttpClient, ) { + @Suppress("SENSELESS_COMPARISON") val tag = if (BuildConfig.FLAVOR == "play") { "RegisterAccounts FirebaseMsgService" @@ -51,70 +52,54 @@ class RegisterAccounts( private suspend fun signAllAuths( notificationToken: String, - remainingTos: List>>, - output: MutableList, - onReady: (List) -> Unit, - ) { + remainingTos: List, + ): List { if (remainingTos.isEmpty()) { - onReady(output) - return + return emptyList() } - launchAndWaitAll(remainingTos) { accountRelayPair -> - val result = - tryAndWait { continuation -> - val signer = accountRelayPair.first.createSigner() - // TODO: Modify the external launcher to launch as different users. - // Right now it only registers if Amber has already approved this signature - if (signer is NostrSignerExternal) { - signer.launcher.registerLauncher( - launcher = { }, - contentResolver = Amethyst.instance::contentResolverFn, - ) - } - - RelayAuthEvent.create(accountRelayPair.second, notificationToken, signer) { result -> - continuation.resume(result) - } - } - - if (result != null) { - output.add(result) - } + return mapNotNullAsync(remainingTos) { info -> + val signer = info.accountSettings.createSigner(Amethyst.instance.contentResolver) + RelayAuthEvent.create(info.relays, notificationToken, signer) } - - onReady(output) } + class Registration( + val accountSettings: AccountSettings, + val relays: List, + ) + // creates proof that it controls all accounts private suspend fun signEventsToProveControlOfAccounts( accounts: List, notificationToken: String, - onReady: (List) -> Unit, - ) { + ): List { val readyToSend = accounts - .mapNotNull { - Log.d(tag, "Register Account ${it.npub}") + .mapNotNull { account -> + if (account.hasPrivKey || account.loggedInWithExternalSigner) { + Log.d(tag, "Register Account ${account.npub}") - val acc = LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub) - if (acc != null && acc.isWriteable()) { - val nip65Read = acc.backupNIP65RelayList?.readRelays() ?: emptyList() + val acc = LocalPreferences.loadCurrentAccountFromEncryptedStorage(account.npub) + if (acc != null && acc.isWriteable()) { + val nip65Read = acc.backupNIP65RelayList?.readRelaysNorm() ?: emptyList() + val nip17Read = acc.backupDMRelayList?.relays() ?: emptyList() - Log.d(tag, "Register Account ${it.npub} NIP65 Reads ${nip65Read.joinToString(", ")}") + if (isDebug) { + val readRelays = nip65Read.joinToString(", ") { it.url } + Log.d(tag, "Register Account ${account.npub} NIP65 Reads $readRelays") - val nip17Read = acc.backupDMRelayList?.relays() ?: emptyList() + val dmRelays = nip17Read.joinToString(", ") { it.url } + Log.d(tag, "Register Account ${account.npub} NIP17 Reads $dmRelays") + } - Log.d(tag, "Register Account ${it.npub} NIP17 Reads ${nip17Read.joinToString(", ")}") + val relays = (nip65Read + nip17Read) - val readKind3Relays = acc.backupContactList?.relays()?.mapNotNull { if (it.value.read) it.key else null } ?: emptyList() - - Log.d(tag, "Register Account ${it.npub} Kind3 Reads ${readKind3Relays.joinToString(", ")}") - - val relays = (nip65Read + nip17Read + readKind3Relays) - - if (relays.isNotEmpty()) { - Pair(acc, relays) + if (relays.isNotEmpty()) { + Registration(acc, relays) + } else { + null + } } else { null } @@ -123,16 +108,10 @@ class RegisterAccounts( } } - val listOfAuthEvents = mutableListOf() - signAllAuths( - notificationToken, - readyToSend, - listOfAuthEvents, - onReady, - ) + return signAllAuths(notificationToken, readyToSend) } - fun postRegistrationEvent(events: List) { + suspend fun postRegistrationEvent(events: List) { val jsonObject = """{ "events": [ ${events.joinToString(", ") { it.toJson() }} ] @@ -146,19 +125,21 @@ class RegisterAccounts( val request = Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(url) .post(body) .build() - val isSucess = client(url).newCall(request).execute().use { it.isSuccessful } - Log.i(tag, "Server registration $isSucess") + val client = client(url) + + client.newCall(request).executeAsync().use { response -> + Log.i(tag, "Server registration ${response.isSuccessful}") + } } suspend fun go(notificationToken: String) { if (notificationToken.isNotEmpty()) { withContext(Dispatchers.IO) { - signEventsToProveControlOfAccounts(accounts, notificationToken) { postRegistrationEvent(it) } + postRegistrationEvent(signEventsToProveControlOfAccounts(accounts, notificationToken)) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DefaultContentTypeInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DefaultContentTypeInterceptor.kt index e53ae2ee1c..b32b04641a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DefaultContentTypeInterceptor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DefaultContentTypeInterceptor.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt index 55963b05b0..4ffa9f7b75 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt @@ -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 @@ -39,7 +39,7 @@ class DualHttpClientManager( ) { val factory = OkHttpClientFactory(keyCache) - private val defaultHttpClient: StateFlow = + val defaultHttpClient: StateFlow = combine(proxyPortProvider, isMobileDataProvider) { proxy, mobile -> factory.buildHttpClient(proxy, mobile, userAgent) }.stateIn( @@ -48,7 +48,7 @@ class DualHttpClientManager( factory.buildHttpClient(proxyPortProvider.value, isMobileDataProvider.value, userAgent), ) - private val defaultHttpClientWithoutProxy: StateFlow = + val defaultHttpClientWithoutProxy: StateFlow = isMobileDataProvider .map { mobile -> factory.buildHttpClient(mobile, userAgent) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptedBlobInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptedBlobInterceptor.kt index 789e16c1e3..bd71c92857 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptedBlobInterceptor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptedBlobInterceptor.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptionKeyCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptionKeyCache.kt index 6677491e13..5d31a668f3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptionKeyCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptionKeyCache.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LoggingInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LoggingInterceptor.kt index 4f55de1bf7..e64baa7088 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LoggingInterceptor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LoggingInterceptor.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt index 97ddc8f243..bf87bdbcbb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt @@ -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,25 +20,49 @@ */ package com.vitorpamplona.amethyst.service.okhttp +import okhttp3.Dispatcher import okhttp3.OkHttpClient import java.net.InetSocketAddress import java.net.Proxy import java.time.Duration class OkHttpClientFactory( - val keyCache: EncryptionKeyCache, + keyCache: EncryptionKeyCache, ) { companion object { - // by picking a random proxy port, the connection will fail as it shouold. + // by picking a random proxy port, the connection will fail as it should. const val DEFAULT_SOCKS_PORT: Int = 9050 const val DEFAULT_IS_MOBILE: Boolean = false const val DEFAULT_TIMEOUT_ON_WIFI_SECS: Int = 10 const val DEFAULT_TIMEOUT_ON_MOBILE_SECS: Int = 30 } + val logging = LoggingInterceptor() + val keyDecryptor = EncryptedBlobInterceptor(keyCache) + + val myDispatcher = + Dispatcher().apply { + maxRequests = 512 + } + + /* + DEBUG OK HTTP connections here. + init { + if (isDebug) { + GlobalScope.launch(Dispatchers.IO) { + while (true) { + Log.d("OkHttpClientFactory", "Active threads ${myDispatcher.runningCallsCount()}") + delay(5000) + } + } + } + } + */ + private val rootClient = OkHttpClient .Builder() + .dispatcher(myDispatcher) .followRedirects(true) .followSslRedirects(true) .build() @@ -57,8 +81,8 @@ class OkHttpClientFactory( .connectTimeout(duration) .writeTimeout(duration) .addInterceptor(DefaultContentTypeInterceptor(userAgent)) - .addNetworkInterceptor(LoggingInterceptor()) - .addNetworkInterceptor(EncryptedBlobInterceptor(keyCache)) + .addNetworkInterceptor(logging) + .addNetworkInterceptor(keyDecryptor) .build() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt index 94b6a4de24..476f17d12f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt @@ -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,27 +20,49 @@ */ package com.vitorpamplona.amethyst.service.okhttp +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder -import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilderFactory import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response class OkHttpWebSocket( - val url: String, - val forceProxy: Boolean, - val httpClient: (url: String, forceProxy: Boolean) -> OkHttpClient, + val url: NormalizedRelayUrl, + val httpClient: (url: NormalizedRelayUrl) -> OkHttpClient, val out: WebSocketListener, ) : WebSocket { private val listener = OkHttpWebsocketListener() + private var usingOkHttp: OkHttpClient? = null private var socket: okhttp3.WebSocket? = null - fun buildRequest() = Request.Builder().url(url.trim()).build() + fun buildRequest() = Request.Builder().url(url.url).build() + + override fun needsReconnect(): Boolean { + val myUsingOkHttp = usingOkHttp + if (myUsingOkHttp == null) return true + + val currentOkHttp = httpClient(url) + + val usingProxy = myUsingOkHttp.proxy + val currentProxy = currentOkHttp.proxy + + if (usingProxy != null && currentProxy != null && usingProxy != currentProxy) return true + if (usingProxy == null && currentProxy != null) return true + if (usingProxy != null && currentProxy == null) return true + + if (currentOkHttp.readTimeoutMillis != myUsingOkHttp.readTimeoutMillis) return true + if (currentOkHttp.writeTimeoutMillis != myUsingOkHttp.writeTimeoutMillis) return true + if (currentOkHttp.connectTimeoutMillis != myUsingOkHttp.connectTimeoutMillis) return true + if (currentOkHttp.callTimeoutMillis != myUsingOkHttp.callTimeoutMillis) return true + + return false + } override fun connect() { - socket = httpClient(url, forceProxy).newWebSocket(buildRequest(), listener) + usingOkHttp = httpClient(url) + socket = usingOkHttp?.newWebSocket(buildRequest(), listener) } inner class OkHttpWebsocketListener : okhttp3.WebSocketListener() { @@ -49,7 +71,7 @@ class OkHttpWebSocket( response: Response, ) = out.onOpen( response.receivedResponseAtMillis - response.sentRequestAtMillis, - response.headers.get("Sec-WebSocket-Extensions")?.contains("permessage-deflate") ?: false, + response.headers["Sec-WebSocket-Extensions"]?.contains("permessage-deflate") ?: false, ) override fun onMessage( @@ -73,30 +95,21 @@ class OkHttpWebSocket( webSocket: okhttp3.WebSocket, t: Throwable, response: Response?, - ) = out.onFailure(t, response?.message) + ) = out.onFailure(t, response?.code, response?.message) } class Builder( - val forceProxy: Boolean, - val httpClient: (String, Boolean) -> OkHttpClient, + val httpClient: (NormalizedRelayUrl) -> OkHttpClient, ) : WebsocketBuilder { // Called when connecting. override fun build( - url: String, + url: NormalizedRelayUrl, out: WebSocketListener, - ) = OkHttpWebSocket(url, forceProxy, httpClient, out) + ) = OkHttpWebSocket(url, httpClient, out) } - class BuilderFactory( - val httpClient: (String, Boolean) -> OkHttpClient, - ) : WebsocketBuilderFactory { - override fun build( - url: String, - forceProxy: Boolean, - ) = Builder(forceProxy, httpClient) - } - - override fun cancel() { + override fun disconnect() { + // uses cancel to kill the SEND stack that might be waiting socket?.cancel() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/ProxySettingsAnchor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/ProxySettingsAnchor.kt new file mode 100644 index 0000000000..37af51d960 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/ProxySettingsAnchor.kt @@ -0,0 +1,42 @@ +/** + * 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.service.okhttp + +import com.vitorpamplona.amethyst.model.torState.TorRelayEvaluation +import com.vitorpamplona.amethyst.model.torState.TorRelaySettings +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class ProxySettingsAnchor { + val flow: MutableStateFlow> = + MutableStateFlow( + MutableStateFlow( + TorRelayEvaluation( + torSettings = TorRelaySettings(), + trustedRelayList = emptySet(), + dmRelayList = emptySet(), + ), + ), + ) + + var useProxy: (NormalizedRelayUrl) -> Boolean = { flow.value.value.useTor(it) } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpBitcoinExplorer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpBitcoinExplorer.kt index f3d684f06f..7ec04b0e9b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpBitcoinExplorer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpBitcoinExplorer.kt @@ -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,7 +22,6 @@ package com.vitorpamplona.amethyst.service.ots import android.util.Log import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.quartz.nip03Timestamp.ots.BitcoinExplorer import com.vitorpamplona.quartz.nip03Timestamp.ots.BlockHeader import com.vitorpamplona.quartz.nip03Timestamp.ots.exceptions.UrlException @@ -51,7 +50,6 @@ class OkHttpBitcoinExplorer( val request = Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .header("Accept", "application/json") .url(url) .get() @@ -95,7 +93,6 @@ class OkHttpBitcoinExplorer( val request = Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(url) .get() .build() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendar.kt index 73a763a65e..b8af7b7efc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendar.kt @@ -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,7 +20,6 @@ */ package com.vitorpamplona.amethyst.service.ots -import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.quartz.nip03Timestamp.ots.ICalendar import com.vitorpamplona.quartz.nip03Timestamp.ots.StreamDeserializationContext import com.vitorpamplona.quartz.nip03Timestamp.ots.Timestamp @@ -60,7 +59,6 @@ class OkHttpCalendar( val request = okhttp3.Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .header("Accept", "application/vnd.opentimestamps.v1") .header("Content-Type", "application/x-www-form-urlencoded") .url(url) @@ -112,7 +110,6 @@ class OkHttpCalendar( val request = okhttp3.Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .header("Accept", "application/vnd.opentimestamps.v1") .header("Content-Type", "application/x-www-form-urlencoded") .url(url) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarAsyncSubmit.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarAsyncSubmit.kt index 19e0cbae7d..c412fc0c00 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarAsyncSubmit.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarAsyncSubmit.kt @@ -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,7 +20,6 @@ */ package com.vitorpamplona.amethyst.service.ots -import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.quartz.nip03Timestamp.ots.ICalendarAsyncSubmit import com.vitorpamplona.quartz.nip03Timestamp.ots.StreamDeserializationContext import com.vitorpamplona.quartz.nip03Timestamp.ots.Timestamp @@ -45,7 +44,6 @@ class OkHttpCalendarAsyncSubmit( val request = okhttp3.Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .header("Accept", "application/vnd.opentimestamps.v1") .header("Content-Type", "application/x-www-form-urlencoded") .url(url) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarBuilder.kt index 800560cd2f..7b079f1db7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarBuilder.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OtsResolverBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpOtsResolverBuilder.kt similarity index 68% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OtsResolverBuilder.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpOtsResolverBuilder.kt index 7a872e534d..d858cdd6b6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OtsResolverBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpOtsResolverBuilder.kt @@ -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,8 +22,13 @@ package com.vitorpamplona.amethyst.service.ots import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver +import com.vitorpamplona.quartz.nip03Timestamp.OtsResolverBuilder -class OtsResolverBuilder { +class OkHttpOtsResolverBuilder( + val okHttpClients: DualHttpClientManager, + val shouldUseTorForUrl: (String) -> Boolean, + val cache: OtsBlockHeightCache, +) : OtsResolverBuilder { fun getAPI(usingTor: Boolean) = if (usingTor) { OkHttpBitcoinExplorer.MEMPOOL_API_URL @@ -31,22 +36,20 @@ class OtsResolverBuilder { OkHttpBitcoinExplorer.BLOCKSTREAM_API_URL } - fun build( - okHttpClients: DualHttpClientManager, - shouldUseTorForUrl: (String) -> Boolean, - cache: OtsBlockHeightCache, - ): OtsResolver { + override fun build(): OtsResolver { val shouldUseTor = shouldUseTorForUrl(OkHttpBitcoinExplorer.MEMPOOL_API_URL) return OtsResolver( - OkHttpBitcoinExplorer( - getAPI(shouldUseTor), - okHttpClients.getHttpClient(shouldUseTor), - cache, - ), - OkHttpCalendarBuilder { - okHttpClients.getHttpClient(shouldUseTorForUrl(it)) - }, + explorer = + OkHttpBitcoinExplorer( + baseAPI = getAPI(usingTor = shouldUseTor), + client = okHttpClients.getHttpClient(shouldUseTor), + cache = cache, + ), + calendarBuilder = + OkHttpCalendarBuilder { + okHttpClients.getHttpClient(shouldUseTorForUrl(it)) + }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OtsBlockHeightCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OtsBlockHeightCache.kt index 1d8a614a58..b618fdb287 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OtsBlockHeightCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OtsBlockHeightCache.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/ControlWhenPlayerIsActive.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/ControlWhenPlayerIsActive.kt index db428a52f5..19fcc64821 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/ControlWhenPlayerIsActive.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/ControlWhenPlayerIsActive.kt @@ -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,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.playback.composable +import android.view.View import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -67,23 +68,30 @@ fun ControlWhenPlayerIsActive( // Keeps the screen on while playing and viewing videos. DisposableEffect(key1 = controller, key2 = view) { - val listener = - object : Player.Listener { - override fun onIsPlayingChanged(isPlaying: Boolean) { - // doesn't consider the mutex because the screen can turn off if the video - // being played in the mutex is not visible. - if (view.keepScreenOn != isPlaying) { - view.keepScreenOn = isPlaying - } - } - } + val listener = PlayerEventListener(view) controller.addListener(listener) onDispose { - if (view.keepScreenOn) { - view.keepScreenOn = false - } controller.removeListener(listener) + listener.destroy() + } + } +} + +class PlayerEventListener( + val view: View, +) : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + // doesn't consider the mutex because the screen can turn off if the video + // being played in the mutex is not visible. + if (view.keepScreenOn != isPlaying) { + view.keepScreenOn = isPlaying + } + } + + fun destroy() { + if (view.keepScreenOn) { + view.keepScreenOn = false } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt index 5c49807d81..f264bc9f53 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt @@ -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 @@ -54,6 +54,10 @@ fun GetVideoController( val scope = rememberCoroutineScope() // Prepares a VideoPlayer from the foreground service. + // + // TODO: Review this code because a new Disposable Effect can run + // before the onDispose of the previous composable and the onDispose + // sometimes affects the new variables, not the old ones. DisposableEffect(key1 = mediaItem.src.videoUri) { // If it is not null, the user might have come back from a playing video, like clicking on // the notification of the video player. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/LoadThumbAndThenVideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/LoadThumbAndThenVideoView.kt index 9ce2659605..04545cdf37 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/LoadThumbAndThenVideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/LoadThumbAndThenVideoView.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/MediaControllerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/MediaControllerState.kt index 24131f5a7b..8316c56204 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/MediaControllerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/MediaControllerState.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/PlayerSurface.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/PlayerSurface.kt index 5e9499f9eb..971e1e6f5b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/PlayerSurface.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/PlayerSurface.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt index 597f932e97..1a9e667062 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt @@ -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 @@ -40,7 +40,6 @@ import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderCon import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.experimental.audio.header.tags.WaveformTag @Composable @OptIn(UnstableApi::class) @@ -50,7 +49,7 @@ fun RenderVideoPlayer( thumbData: VideoThumb?, showControls: Boolean = true, contentScale: ContentScale, - waveform: WaveformTag? = null, + waveform: WaveformData? = null, borderModifier: Modifier, videoModifier: Modifier, onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoThumb.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoThumb.kt index 2d3759abcd..c5d6bec8af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoThumb.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoThumb.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt index 7d7ec1a987..8b31f98922 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt @@ -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 @@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -42,9 +43,13 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Size75dp import com.vitorpamplona.amethyst.ui.theme.imageModifier import com.vitorpamplona.amethyst.ui.theme.videoGalleryModifier -import com.vitorpamplona.quartz.experimental.audio.header.tags.WaveformTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +@Immutable +class WaveformData( + val wave: List, +) + @Composable fun VideoView( videoUri: String, @@ -54,7 +59,7 @@ fun VideoView( roundedCorner: Boolean, gallery: Boolean = false, contentScale: ContentScale, - waveform: WaveformTag? = null, + waveform: WaveformData? = null, artworkUri: String? = null, authorName: String? = null, dimensions: DimensionTag? = null, @@ -85,7 +90,7 @@ fun VideoView( thumb: VideoThumb? = null, borderModifier: Modifier, contentScale: ContentScale, - waveform: WaveformTag? = null, + waveform: WaveformData? = null, artworkUri: String? = null, authorName: String? = null, dimensions: DimensionTag? = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt index 1f808f36e6..03aca3d3d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt @@ -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 @@ -29,7 +29,6 @@ import androidx.compose.ui.layout.ContentScale import com.vitorpamplona.amethyst.service.playback.composable.mainVideo.VideoPlayerActiveMutex import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.experimental.audio.header.tags.WaveformTag public val DEFAULT_MUTED_SETTING = mutableStateOf(true) @@ -43,7 +42,7 @@ fun VideoViewInner( showControls: Boolean = true, contentScale: ContentScale, borderModifier: Modifier, - waveform: WaveformTag? = null, + waveform: WaveformData? = null, artworkUri: String? = null, authorName: String? = null, nostrUriCallback: String? = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/MuteButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/MuteButton.kt index a2f796eba2..39fbc7339f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/MuteButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/MuteButton.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PictureInPictureButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PictureInPictureButton.kt index 1444ae2bb3..a0b5cfa7d1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PictureInPictureButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PictureInPictureButton.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControlButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControlButtons.kt index 585b9a7fd5..e2f95fadbf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControlButtons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControlButtons.kt @@ -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,19 +20,16 @@ */ package com.vitorpamplona.amethyst.service.playback.composable.controls -import android.content.Context import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity -import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk import com.vitorpamplona.amethyst.ui.components.ShareImageAction import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -70,7 +67,7 @@ fun RenderControlButtons( if (!isLiveStreaming(mediaData.videoUri)) { AnimatedSaveButton(controllerVisible, buttonPositionModifier.padding(end = Size110dp)) { context -> - saveMediaToGalleryInner(mediaData.videoUri, mediaData.mimeType, context, accountViewModel) + accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context) } AnimatedShareButton(controllerVisible, buttonPositionModifier.padding(end = Size165dp)) { popupExpanded, toggle -> @@ -82,23 +79,3 @@ fun RenderControlButtons( } } } - -private fun saveMediaToGalleryInner( - videoUri: String?, - mimeType: String?, - localContext: Context, - accountViewModel: AccountViewModel, -) { - MediaSaverToDisk.saveDownloadingIfNeeded( - videoUri = videoUri, - okHttpClient = accountViewModel::okHttpClientForVideo, - mimeType = mimeType, - localContext = localContext, - onSuccess = { - accountViewModel.toastManager.toast(R.string.video_saved_to_the_gallery, R.string.video_saved_to_the_gallery) - }, - onError = { - accountViewModel.toastManager.toast(R.string.failed_to_save_the_video, null, it) - }, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SaveButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SaveMediaButton.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SaveButton.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SaveMediaButton.kt index 12e48a6083..8d7a02d032 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SaveButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SaveMediaButton.kt @@ -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 @@ -66,13 +66,13 @@ fun AnimatedSaveButton( enter = remember { fadeIn() }, exit = remember { fadeOut() }, ) { - SaveButton(onSaveClick) + SaveMediaButton(onSaveClick) } } -@kotlin.OptIn(ExperimentalPermissionsApi::class) +@OptIn(ExperimentalPermissionsApi::class) @Composable -fun SaveButton(onSaveClick: (localContext: Context) -> Unit) { +fun SaveMediaButton(onSaveClick: (localContext: Context) -> Unit) { Box(modifier = PinBottomIconSize) { Box( Modifier diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/ShareButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/ShareButton.kt index f099e1ad79..288a2e4bd8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/ShareButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/ShareButton.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mainVideo/VideoPlayerActiveMutex.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mainVideo/VideoPlayerActiveMutex.kt index b22ad4cefd..b1d318fef8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mainVideo/VideoPlayerActiveMutex.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mainVideo/VideoPlayerActiveMutex.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/GetMediaItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/GetMediaItem.kt index da3bbf6851..ab10d943c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/GetMediaItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/GetMediaItem.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCache.kt index 4a6324d698..9298b23fd3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCache.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemData.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemData.kt index ddee356373..d4b664ff20 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemData.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemData.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/Waveform.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/Waveform.kt index 91cf55c90a..c0ef0c8659 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/Waveform.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/Waveform.kt @@ -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 @@ -38,15 +38,15 @@ import androidx.media3.common.Player import androidx.media3.session.MediaController import com.linc.audiowaveform.infiniteLinearGradient import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState +import com.vitorpamplona.amethyst.service.playback.composable.WaveformData import com.vitorpamplona.amethyst.ui.components.AudioWaveformReadOnly -import com.vitorpamplona.quartz.experimental.audio.header.tags.WaveformTag import kotlinx.coroutines.delay import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flow @Composable fun Waveform( - waveform: WaveformTag, + waveform: WaveformData, mediaControllerState: MediaControllerState, modifier: Modifier, ) { @@ -56,21 +56,25 @@ fun Waveform( val restartFlow = remember { mutableIntStateOf(0) } + val myController = mediaControllerState.controller + // Keeps the screen on while playing and viewing videos. - DisposableEffect(key1 = mediaControllerState.controller) { - val listener = - object : Player.Listener { - override fun onIsPlayingChanged(isPlaying: Boolean) { - // doesn't consider the mutex because the screen can turn off if the video - // being played in the mutex is not visible. - if (isPlaying) { - restartFlow.intValue += 1 + if (myController != null) { + DisposableEffect(key1 = myController) { + val listener = + object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + // doesn't consider the mutex because the screen can turn off if the video + // being played in the mutex is not visible. + if (isPlaying) { + restartFlow.intValue += 1 + } } } - } - mediaControllerState.controller?.addListener(listener) - onDispose { mediaControllerState.controller?.removeListener(listener) } + myController.addListener(listener) + onDispose { myController.removeListener(listener) } + } } LaunchedEffect(key1 = restartFlow.intValue) { @@ -90,7 +94,7 @@ private fun pollCurrentDuration(controller: MediaController) = @Composable fun DrawWaveform( - waveform: WaveformTag, + waveform: WaveformData, waveformProgress: MutableFloatState, modifier: Modifier, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/IsLiveStreaming.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/IsLiveStreaming.kt index e91f335233..d7b55fa6e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/IsLiveStreaming.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/IsLiveStreaming.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/VideoCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/VideoCache.kt index 1662a0453d..63fa8edb77 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/VideoCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/VideoCache.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/VideoCacheFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/VideoCacheFactory.kt index e63c53a504..86c82701cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/VideoCacheFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/VideoCacheFactory.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/ActivityExt.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/ActivityExt.kt index 9574dd6f1c..e5db755b63 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/ActivityExt.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/ActivityExt.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/BackgroundMedia.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/BackgroundMedia.kt index a2aaabdd8b..1649d665e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/BackgroundMedia.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/BackgroundMedia.kt @@ -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,26 +24,6 @@ import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerSta import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient import kotlinx.coroutines.flow.MutableStateFlow -/** - * 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. - */ object BackgroundMedia { // background playing mutex. val bgInstance = MutableStateFlow(null) @@ -59,7 +39,7 @@ object BackgroundMedia { fun removeBackgroundControllerAndReleaseIt() { bgInstance.value?.let { PlaybackServiceClient.removeController(it) - clearBackground() + bgInstance.tryEmit(null) } } @@ -67,7 +47,9 @@ object BackgroundMedia { bgInstance.tryEmit(mediaControllerState) } - fun clearBackground() { - bgInstance.tryEmit(null) + fun clearBackground(mediaControllerState: MediaControllerState) { + if (bgInstance.value == mediaControllerState) { + bgInstance.tryEmit(null) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/IntentExtras.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/IntentExtras.kt index 1b35c8d777..a4ec77431f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/IntentExtras.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/IntentExtras.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PictureInPicture.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PictureInPicture.kt index dd2626cbd3..c2a2008b66 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PictureInPicture.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PictureInPicture.kt @@ -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,9 +58,8 @@ fun rememberIsInPipMode(): Boolean { Consumer { info -> pipMode = info.isInPictureInPictureMode } - activity.addOnPictureInPictureModeChangedListener( - observer, - ) + + activity.addOnPictureInPictureModeChangedListener(observer) onDispose { activity.removeOnPictureInPictureModeChangedListener(observer) } } return pipMode diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt index 4d276595d7..3cde5e550c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt @@ -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 @@ -67,11 +67,6 @@ class PipVideoActivity : ComponentActivity() { super.finish() } - override fun onBackPressed() { - super.onBackPressed() - finishAndRemoveTask() - } - override fun onUserLeaveHint() { super.onUserLeaveHint() finishAndRemoveTask() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt index 9b9e0c6613..168e03897e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt @@ -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 @@ -81,7 +81,7 @@ fun PipVideo(controller: MediaControllerState) { DisposableEffect(controller) { BackgroundMedia.switchKeepPlaying(controller) onDispose { - BackgroundMedia.clearBackground() + BackgroundMedia.clearBackground(controller) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt index 93f3188b0d..3051ab5523 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt index d2643927fc..1828bfe6cf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt index a6257067af..85121a5807 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt @@ -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,9 +21,11 @@ package com.vitorpamplona.amethyst.service.playback.playerPool import android.content.Context +import android.util.Log import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -39,7 +41,14 @@ class ExoPlayerPool( private val playerPool = ConcurrentLinkedQueue() private val poolSize = SimultaneousPlaybackCalculator.max() private val poolStartingSize = 3 - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + // Exists to avoid exceptions stopping the coroutine + val exceptionHandler = + CoroutineExceptionHandler { _, throwable -> + Log.e("BundledInsert", "Caught exception: ${throwable.message}", throwable) + } + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main + exceptionHandler) private val mutex = Mutex() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt index 7f5efa57ad..48bfad6b34 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt @@ -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 @@ -35,6 +35,7 @@ import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @@ -130,6 +131,7 @@ class MediaSessionPool( fun cleanupUnused() { if (lastCleanup < TimeUtils.oneMinuteAgo()) { lastCleanup = TimeUtils.now() + @kotlin.OptIn(DelicateCoroutinesApi::class) GlobalScope.launch(Dispatchers.Main) { var counter = 0 val snap = cache.snapshot() @@ -149,6 +151,7 @@ class MediaSessionPool( } fun destroy() { + @kotlin.OptIn(DelicateCoroutinesApi::class) GlobalScope.launch(Dispatchers.Main) { cache.evictAll() playingMap.forEach { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/SimultaneousPlaybackCalculator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/SimultaneousPlaybackCalculator.kt index cdd74c5a35..ce2f050fe9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/SimultaneousPlaybackCalculator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/SimultaneousPlaybackCalculator.kt @@ -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 @@ -29,26 +29,6 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.mediacodec.MediaCodecUtil import com.vitorpamplona.amethyst.Amethyst -/** - * 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. - */ class SimultaneousPlaybackCalculator { companion object { fun isLowMemory(context: Context): Boolean { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/aspectRatio/AspectRatioCacher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/aspectRatio/AspectRatioCacher.kt index ea45885253..3228e722e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/aspectRatio/AspectRatioCacher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/aspectRatio/AspectRatioCacher.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/positions/CurrentPlayPositionCacher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/positions/CurrentPlayPositionCacher.kt index 55ee84b172..d7e1f5f113 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/positions/CurrentPlayPositionCacher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/positions/CurrentPlayPositionCacher.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/positions/VideoViewedPositionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/positions/VideoViewedPositionCache.kt index e4add44977..e42a6d377c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/positions/VideoViewedPositionCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/positions/VideoViewedPositionCache.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/wake/KeepVideosPlaying.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/wake/KeepVideosPlaying.kt index 9c9f5d2e73..dcb045b6f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/wake/KeepVideosPlaying.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/wake/KeepVideosPlaying.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt index 342cba34d1..0232d843c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt index 321b82742d..1018b3813c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt @@ -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 @@ -28,6 +28,7 @@ import androidx.media3.session.MediaController import androidx.media3.session.SessionToken import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @@ -45,6 +46,7 @@ object PlaybackServiceClient { // release when can if (myController != null) { mediaControllerState.controller = null + @OptIn(DelicateCoroutinesApi::class) GlobalScope.launch(Dispatchers.Main) { // myController.pause() // myController.stop() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/websocket/WssDataStreamCollector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/websocket/WssDataStreamCollector.kt index 826e2f395f..0ca26eccf0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/websocket/WssDataStreamCollector.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/websocket/WssDataStreamCollector.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/websocket/WssStreamDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/websocket/WssStreamDataSource.kt index 4f2b150895..b942f30121 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/websocket/WssStreamDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/websocket/WssStreamDataSource.kt @@ -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,6 +22,7 @@ package com.vitorpamplona.amethyst.service.playback.websocket import android.net.Uri import androidx.annotation.OptIn +import androidx.core.net.toUri import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.BaseDataSource import androidx.media3.datasource.DataSource @@ -64,7 +65,7 @@ class WssStreamDataSource( override fun getUri(): Uri? { webSocketClient?.request()?.url?.let { - return Uri.parse(it.toString()) + return it.toString().toUri() } return null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/HtmlParser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/HtmlParser.kt index d43077047f..9f77cd2f29 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/HtmlParser.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/HtmlParser.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/OpenGraphParser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/OpenGraphParser.kt index 62ce543668..cec53b3112 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/OpenGraphParser.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/OpenGraphParser.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlInfoItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlInfoItem.kt index 720abcdabf..e2f4834825 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlInfoItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlInfoItem.kt @@ -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,7 +21,6 @@ package com.vitorpamplona.amethyst.service.previews import androidx.compose.runtime.Immutable -import okhttp3.MediaType import java.net.URL @Immutable @@ -30,7 +29,7 @@ class UrlInfoItem( val title: String = "", val description: String = "", val image: String = "", - val mimeType: MediaType, + val mimeType: String, ) { val verifiedUrl = kotlin.runCatching { URL(url) }.getOrNull() val imageUrlFullPath = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt index 52f4332eb7..3d25f38866 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt @@ -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,13 +20,13 @@ */ package com.vitorpamplona.amethyst.service.previews -import com.vitorpamplona.amethyst.service.checkNotInMainThread import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.coroutines.executeAsync class UrlPreview { suspend fun fetch( @@ -52,24 +52,29 @@ class UrlPreview { .url(url) .get() .build() - okHttpClient(url).newCall(request).execute().use { - checkNotInMainThread() - if (it.isSuccessful) { - val mimeType = - it.headers["Content-Type"]?.toMediaType() - ?: throw IllegalArgumentException("Website returned unknown mimetype: ${it.headers["Content-Type"]}") - if (mimeType.type == "text" && mimeType.subtype == "html") { - val data = OpenGraphParser().extractUrlInfo(HtmlParser().parseHtml(it.body.source(), mimeType)) - UrlInfoItem(url, data.title, data.description, data.image, mimeType) - } else if (mimeType.type == "image") { - UrlInfoItem(url, image = url, mimeType = mimeType) - } else if (mimeType.type == "video") { - UrlInfoItem(url, image = url, mimeType = mimeType) + + val client = okHttpClient(url) + + client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + if (response.isSuccessful) { + val mimeType = + response.headers["Content-Type"]?.toMediaType() + ?: throw IllegalArgumentException("Website returned unknown mimetype: ${response.headers["Content-Type"]}") + if (mimeType.type == "text" && mimeType.subtype == "html") { + val metaTags = HtmlParser().parseHtml(response.body.source(), mimeType) + val data = OpenGraphParser().extractUrlInfo(metaTags) + UrlInfoItem(url, data.title, data.description, data.image, mimeType.toString()) + } else if (mimeType.type == "image") { + UrlInfoItem(url, image = url, mimeType = mimeType.toString()) + } else if (mimeType.type == "video") { + UrlInfoItem(url, image = url, mimeType = mimeType.toString()) + } else { + throw IllegalArgumentException("Website returned unknown encoding for previews: $mimeType") + } } else { - throw IllegalArgumentException("Website returned unknown encoding for previews: $mimeType") + throw IllegalArgumentException("Website returned: " + response.code) } - } else { - throw IllegalArgumentException("Website returned: " + it.code) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/proxyPort/ProxyPortFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/proxyPort/ProxyPortFlow.kt deleted file mode 100644 index 3141a0a968..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/proxyPort/ProxyPortFlow.kt +++ /dev/null @@ -1,93 +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.service.proxyPort - -import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus -import com.vitorpamplona.amethyst.ui.tor.TorType -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map - -class ProxyPortFlow( - torType: MutableStateFlow, - externalSocksPort: MutableStateFlow, - torServiceStatus: StateFlow, -) { - @OptIn(ExperimentalCoroutinesApi::class) - val status = - torType - .flatMapLatest { torType -> - when (torType) { - TorType.INTERNAL -> { - // subscribing to status turns Tor service on - torServiceStatus.map { - if (it is TorServiceStatus.Active) { - it.port - } else { - null - } - } - } - - TorType.EXTERNAL -> { - externalSocksPort.map { port -> - if (port > 0) { - port - } else { - null - } - } - } - - else -> MutableStateFlow(null) - } - }.distinctUntilChanged() - - companion object { - fun computePort( - torType: TorType, - externalPort: Int, - status: TorServiceStatus, - ): Int? = - when (torType) { - TorType.INTERNAL -> { - if (status is TorServiceStatus.Active && status.port > 0) { - status.port - } else { - null - } - } - - TorType.EXTERNAL -> { - if (externalPort > 0) { - externalPort - } else { - null - } - } - - else -> null - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/CacheClientConnector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/CacheClientConnector.kt new file mode 100644 index 0000000000..0371fc4fe0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/CacheClientConnector.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.LocalCache.markAsSeen +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.EventCollector +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayInsertConfirmationCollector +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CoroutineScope + +class CacheClientConnector( + val client: NostrClient, + val cache: LocalCache, + val scope: CoroutineScope, +) { + val receiver = + EventCollector(client) { event, relay -> + cache.justConsume(event, relay, false) + } + + val confirmationWatcher = + RelayInsertConfirmationCollector(client) { eventId, relay -> + cache.markAsSeen(eventId, relay.url) + markAsSeen(eventId, relay.url) + } + + fun destroy() { + receiver.destroy() + confirmationWatcher.destroy() + } + + private fun markAsSeen( + eventId: HexKey, + info: NormalizedRelayUrl, + ) = LocalCache.getNoteIfExists(eventId)?.addRelay(info) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/KeyDataSourceSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/KeyDataSourceSubscription.kt new file mode 100644 index 0000000000..039b715fea --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/KeyDataSourceSubscription.kt @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.MutableComposeSubscriptionManager +import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.MutableQueryState + +@Composable +fun KeyDataSourceSubscription( + state: T, + dataSource: ComposeSubscriptionManager, +) = DisposableEffect(state) { + dataSource.subscribe(state) + onDispose { + dataSource.unsubscribe(state) + } +} + +@Composable +fun KeyDataSourceSubscription( + state: T, + dataSource: MutableComposeSubscriptionManager, +) = DisposableEffect(state) { + dataSource.subscribe(state) + onDispose { + dataSource.unsubscribe(state) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayLogger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayLogger.kt new file mode 100644 index 0000000000..6b33107256 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayLogger.kt @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient + +import android.util.Log +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient + +/** + * Listens to NostrClient's onNotify messages from the relay + */ +class RelayLogger( + val client: NostrClient, +) { + companion object { + val TAG = RelayLogger::class.java.simpleName + } + + private val clientListener = + object : IRelayClientListener { + /** A new message was received */ + override fun onEvent( + relay: IRelayClient, + subId: String, + event: Event, + arrivalTime: Long, + afterEOSE: Boolean, + ) { + Log.d(TAG, "Relay onEVENT ${relay.url} ($subId - $afterEOSE) ${event.toJson()}") + } + + override fun onSend( + relay: IRelayClient, + msg: String, + success: Boolean, + ) { + Log.d(TAG, "Relay send ${relay.url} (${msg.length} chars) $msg") + } + } + + init { + Log.d(TAG, "Init, Subscribe") + client.subscribe(clientListener) + } + + fun destroy() { + // makes sure to run + Log.d(TAG, "Destroy, Unsubscribe") + client.unsubscribe(clientListener) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt new file mode 100644 index 0000000000..fdc54672d2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt @@ -0,0 +1,72 @@ +/** + * 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.service.relayClient + +import android.util.Log +import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager +import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager +import com.vitorpamplona.amethyst.service.okhttp.ProxySettingsAnchor +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +class RelayProxyClientConnector( + val torProxySettingsAnchor: ProxySettingsAnchor, + val okHttpClients: DualHttpClientManager, + val connManager: ConnectivityManager, + val client: NostrClient, + val scope: CoroutineScope, +) { + @OptIn(FlowPreview::class) + val relayServices = + combine( + torProxySettingsAnchor.flow, + okHttpClients.defaultHttpClient, + okHttpClients.defaultHttpClientWithoutProxy, + connManager.status, + ) { torSettings, torConnection, clearConnection, connectivity -> + torSettings.hashCode() + torConnection.hashCode() + clearConnection.hashCode() + connectivity.hashCode() + }.debounce(100) + .onEach { + Log.d("ManageRelayServices", "Relay Services have changed") + client.reconnect(true) + }.onStart { + Log.d("ManageRelayServices", "Resuming Relay Services") + client.connect() + }.onCompletion { + Log.d("ManageRelayServices", "Pausing Relay Services") + client.disconnect() + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.WhileSubscribed(30000), + null, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/WatchLifecycleAndRefreshDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt similarity index 56% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/WatchLifecycleAndRefreshDataSource.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt index 79c751e1d6..c2b4047bda 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/WatchLifecycleAndRefreshDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt @@ -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,29 +18,35 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms +package com.vitorpamplona.amethyst.service.relayClient.authCommand.compose import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner -import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.ScreenAuthAccount import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable -fun WatchLifecycleAndRefreshDataSource(accountViewModel: AccountViewModel) { - val lifeCycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - NostrChatroomListDataSource.account = accountViewModel.account - NostrChatroomListDataSource.start() - } - } +fun RelayAuthSubscription(accountViewModel: AccountViewModel) = RelayAuthSubscription(accountViewModel, Amethyst.instance.authCoordinator) - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } +@Composable +fun RelayAuthSubscription( + accountViewModel: AccountViewModel, + dataSource: AuthCoordinator, +) { + // different screens get different states + // even if they are tracking the same tag. + val state = + remember(accountViewModel) { + ScreenAuthAccount(accountViewModel.account) + } + + DisposableEffect(state) { + dataSource.subscribe(state) + onDispose { + dataSource.unsubscribe(state) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt new file mode 100644 index 0000000000..1c9598817f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt @@ -0,0 +1,74 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.authCommand.model + +import android.util.Log +import com.vitorpamplona.amethyst.isDebug +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayAuthenticator +import kotlinx.coroutines.CoroutineScope + +class ScreenAuthAccount( + val account: Account, +) + +class AuthCoordinator( + client: NostrClient, + scope: CoroutineScope, +) { + private val authWithAccounts = ListWithUniqueSetCache { it.account } + + val receiver = + RelayAuthenticator(client, scope) { challenge, relay -> + authWithAccounts.distinct().forEach { + if (it.isWriteable()) { + it.sendAuthEvent(relay, challenge) + } + } + } + + fun destroy() { + receiver.destroy() + } + + // This is called by main. Keep it really fast. + fun subscribe(account: ScreenAuthAccount?) { + if (account == null) return + + if (isDebug) { + Log.d(this::class.simpleName, "Watch $account") + } + + authWithAccounts.add(account) + } + + // This is called by main. Keep it really fast. + fun unsubscribe(account: ScreenAuthAccount?) { + if (account == null) return + + if (isDebug) { + Log.d(this::class.simpleName, "Unwatch $account") + } + + authWithAccounts.remove(account) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/ListWithUniqueSetCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/ListWithUniqueSetCache.kt new file mode 100644 index 0000000000..92d1630636 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/ListWithUniqueSetCache.kt @@ -0,0 +1,64 @@ +/** + * 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.service.relayClient.authCommand.model + +import java.util.concurrent.atomic.AtomicReference + +class ListWithUniqueSetCache( + val key: (T) -> U, +) { + private val list = AtomicReference(listOf()) + private val cacheSet = AtomicReference?>(setOf()) + + fun isEmpty() = list.get().isEmpty() + + fun add(item: T) = set(list.get() + item) + + fun remove(item: T) = set(list.get() - item) + + fun set(newList: List) { + list.set(newList) + // Invalidate the cache - next read will recompute + cacheSet.set(null) + } + + fun distinct(): Set { + var currentSet = cacheSet.get() + + // Check if the cached set is based on the current list + if (currentSet != null) { + return currentSet + } + + // Compute and attempt to atomically update the cache + val newSet = list.get().mapTo(mutableSetOf(), key) + cacheSet.compareAndSet(currentSet, newSet) + return newSet + } + + fun forEachSubscriber(action: (T) -> Unit) { + list.get().forEach(action) + } + + fun forEachUniqueSubscriber(action: (U) -> Unit) { + distinct().forEach(action) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt new file mode 100644 index 0000000000..4c9bb3f66f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers + +import java.util.concurrent.ConcurrentHashMap + +/** + * This allows composables to directly register their queries + * to relays. There may be multiple duplications in these + * subscriptions since we do not control when screens are removed. + */ +abstract class ComposeSubscriptionManager : ComposeSubscriptionManagerControls { + private var composeSubscriptions: ConcurrentHashMap = ConcurrentHashMap() + + // This is called by main. Keep it really fast. + fun subscribe(query: T?) { + if (query == null) return + + composeSubscriptions.put(query, query) + + invalidateKeys() + } + + // This is called by main. Keep it really fast. + fun unsubscribe(query: T?) { + if (query == null) return + + composeSubscriptions.remove(query) + + invalidateKeys() + } + + fun allKeys() = composeSubscriptions.keys +} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/EOSETime.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/ComposeSubscriptionManagerControls.kt similarity index 79% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/EOSETime.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/ComposeSubscriptionManagerControls.kt index 331abe3a87..479bde071d 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/EOSETime.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/ComposeSubscriptionManagerControls.kt @@ -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,13 +18,14 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.ammolite.relays.filters +package com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers -/* -* Wrapper class to allow changing in EOSE without modifying the list it is included within -*/ -class EOSETime( - var time: Long, -) { - override fun toString(): String = time.toString() +interface ComposeSubscriptionManagerControls { + fun invalidateKeys() + + fun invalidateFilters() + + fun destroy() + + fun printStats() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt new file mode 100644 index 0000000000..9966f901d8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt @@ -0,0 +1,78 @@ +/** + * 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.service.relayClient.composeSubscriptionManagers + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap + +/** + * This allows composables to directly register their queries + * to relays. There may be multiple duplications in these + * subscriptions since we do not control when screens are removed. + * + * This is similar to QueryBasedSubscriptionOrchestrator, but it + * also allows the subscription itself to change over time as a + * flow, which trigger an update on the relay subscriptions + */ +abstract class MutableComposeSubscriptionManager( + val scope: CoroutineScope, +) : ComposeSubscriptionManagerControls { + private var composeSubscriptions: ConcurrentHashMap = ConcurrentHashMap() + + // This is called by main. Keep it really fast. + fun subscribe(query: T?) { + if (query == null) return + + composeSubscriptions[query]?.cancel() + composeSubscriptions[query] = + scope.launch { + query.flow().collectLatest { + invalidateKeys() + } + } + + invalidateKeys() + } + + // This is called by main. Keep it really fast. + fun unsubscribe(query: T?) { + if (query == null) return + + composeSubscriptions[query]?.cancel() + composeSubscriptions.remove(query) + + invalidateKeys() + } + + fun allKeys() = composeSubscriptions.keys + + fun forEachSubscriber(action: (T) -> Unit) { + composeSubscriptions.keys.forEach(action) + } +} + +interface MutableQueryState { + fun flow(): Flow<*> +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/BaseEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/BaseEoseManager.kt new file mode 100644 index 0000000000..ea0c7db732 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/BaseEoseManager.kt @@ -0,0 +1,74 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.eoseManagers + +import android.util.Log +import com.vitorpamplona.amethyst.isDebug +import com.vitorpamplona.ammolite.relays.BundledUpdate +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.SubscriptionController +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.Dispatchers + +abstract class BaseEoseManager( + val client: NostrClient, + val allKeys: () -> Set, +) { + protected val logTag: String = this.javaClass.simpleName + + private val orchestrator = SubscriptionController(client) + + abstract fun updateSubscriptions(keys: Set) + + fun printStats() = orchestrator.printStats(logTag) + + fun newSubscriptionId() = if (isDebug) logTag + newSubId() else newSubId() + + fun getSubscription(subId: String) = orchestrator.getSub(subId) + + fun requestNewSubscription(onEOSE: ((Long, NormalizedRelayUrl) -> Unit)? = null) = orchestrator.requestNewSubscription(newSubscriptionId(), onEOSE) + + fun dismissSubscription(subId: String) = orchestrator.dismissSubscription(subId) + + // Refreshes observers in batches. + private val bundler = BundledUpdate(300, Dispatchers.Default) + + fun invalidateFilters() { + bundler.invalidate { + forceInvalidate() + } + } + + fun forceInvalidate() { + updateSubscriptions(allKeys()) + + orchestrator.updateRelays() + } + + fun destroy() { + bundler.cancel() + orchestrator.destroy() + if (isDebug) { + Log.d(logTag, "Destroy, Unsubscribe") + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt new file mode 100644 index 0000000000..dfb326b121 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt @@ -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.service.relayClient.eoseManagers + +import com.vitorpamplona.amethyst.service.relays.EOSEByKey +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * This query type creates a new relay subscription for every SubID.id() + * that is subscribed into the keys. It is ideal for screen loading that + * can be shared among multiple logged-in users. + * + * This class keeps EOSEs for each SubID.id() for as long as possible and + * shares all EOSEs among all users. + */ +abstract class PerUniqueIdEoseManager( + client: NostrClient, + allKeys: () -> Set, + val invalidateAfterEose: Boolean = false, +) : BaseEoseManager(client, allKeys) { + // long term EOSE cache + private val latestEOSEs = EOSEByKey() + + // map between each query Id and each subscription id + private val userSubscriptionMap = mutableMapOf() + + fun since(key: T) = latestEOSEs.since(id(key)) + + fun newEose( + key: T, + relayUrl: NormalizedRelayUrl, + time: Long, + ) { + latestEOSEs.newEose(id(key), relayUrl, time) + if (invalidateAfterEose) { + invalidateFilters() + } + } + + open fun newSub(key: T): Subscription = + requestNewSubscription { time, relayUrl -> + newEose(key, relayUrl, time) + } + + open fun endSub( + key: U, + subId: String, + ) { + dismissSubscription(subId) + userSubscriptionMap.remove(key) + } + + fun findOrCreateSubFor(key: T): Subscription { + val id = id(key) + val subId = userSubscriptionMap[id] + return if (subId == null) { + newSub(key).also { userSubscriptionMap[id] = it.id } + } else { + getSubscription(subId) ?: newSub(key).also { userSubscriptionMap[id] = it.id } + } + } + + override fun updateSubscriptions(keys: Set) { + val uniqueSubscribedAccounts = keys.distinctBy { id(it) } + + val updated = mutableSetOf() + + uniqueSubscribedAccounts.forEach { + val mainKey = id(it) + val newFilters = updateFilter(it, since(it))?.ifEmpty { null } + findOrCreateSubFor(it).updateFilters(newFilters?.groupByRelay()) + + updated.add(mainKey) + } + + userSubscriptionMap.filter { it.key !in updated }.forEach { + endSub(it.key, it.value) + } + } + + abstract fun updateFilter( + key: T, + since: SincePerRelayMap?, + ): List? + + abstract fun id(key: T): U +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt new file mode 100644 index 0000000000..047238bb00 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt @@ -0,0 +1,111 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.eoseManagers + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relays.EOSEAccountKey +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * This query type creates a new relay subscription for every logged-in + * user AND each follow list they select in the top of the screen. + * + * It is ideal for the home, video, discovery and notification screens + * since they all have lists to choose from. + * + * This class keeps EOSEs for each user & list for as long as possible and + * does NOT share EOSEs with other users. Changing the list will not make the + * app reuse the EOSE because it assumes the filter is going to be different + */ +abstract class PerUserAndFollowListEoseManager( + client: NostrClient, + allKeys: () -> Set, + val invalidateAfterEose: Boolean = false, +) : BaseEoseManager(client, allKeys) { + private val latestEOSEs = EOSEAccountKey() + private val userSubscriptionMap = mutableMapOf() + + fun since(key: T) = latestEOSEs.since(user(key), list(key)) + + fun newEose( + key: T, + relay: NormalizedRelayUrl, + time: Long, + ) = latestEOSEs.newEose(user(key), list(key), relay, time) + + open fun newSub(key: T): Subscription = + requestNewSubscription { time, relayUrl -> + newEose(key, relayUrl, time) + if (invalidateAfterEose) { + invalidateFilters() + } + } + + open fun endSub( + key: User, + subId: String, + ) { + dismissSubscription(subId) + userSubscriptionMap.remove(key) + } + + fun findOrCreateSubFor(key: T): Subscription { + val user = user(key) + val subId = userSubscriptionMap[user] + return if (subId == null) { + newSub(key).also { userSubscriptionMap[user] = it.id } + } else { + getSubscription(subId) ?: newSub(key).also { userSubscriptionMap[user] = it.id } + } + } + + override fun updateSubscriptions(keys: Set) { + val uniqueSubscribedAccounts = keys.distinctBy { user(it) } + + val updated = mutableSetOf() + + uniqueSubscribedAccounts.forEach { + val user = user(it) + val sub = findOrCreateSubFor(it) + val newFilters = updateFilter(it, since(it))?.ifEmpty { null } + sub.updateFilters(newFilters?.groupByRelay()) + updated.add(user) + } + + userSubscriptionMap.filter { it.key !in updated }.forEach { + endSub(it.key, it.value) + } + } + + abstract fun updateFilter( + key: T, + since: SincePerRelayMap?, + ): List? + + abstract fun user(key: T): User + + abstract fun list(key: T): U +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt new file mode 100644 index 0000000000..d0d4b4e672 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt @@ -0,0 +1,108 @@ +/** + * 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.service.relayClient.eoseManagers + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * This query type creates a new relay subscription for every distinct + * user that is subscribed into the keys. It is ideal for screens that + * CANNOT be shared among multiple logged-in users. + * + * This class keeps EOSEs for each user for as long as possible and + * does NOT share EOSEs with other users. The EOSEs are kept even when + * the subscription disappears and comes back later. + */ +abstract class PerUserEoseManager( + client: NostrClient, + allKeys: () -> Set, + val invalidateAfterEose: Boolean = false, +) : BaseEoseManager(client, allKeys) { + private val latestEOSEs = EOSEAccountFast() + private val userSubscriptionMap = mutableMapOf() + + fun since(key: T) = latestEOSEs.since(user(key)) + + fun newEose( + key: T, + relay: NormalizedRelayUrl, + time: Long, + ) = latestEOSEs.newEose(user(key), relay, time) + + open fun newSub(key: T): Subscription = + requestNewSubscription { time, relayUrl -> + newEose(key, relayUrl, time) + if (invalidateAfterEose) { + invalidateFilters() + } + } + + open fun endSub( + key: User, + subId: String, + ) { + dismissSubscription(subId) + userSubscriptionMap.remove(key) + } + + fun findOrCreateSubFor(key: T): Subscription { + val user = user(key) + val subId = userSubscriptionMap[user] + return if (subId == null) { + newSub(key).also { userSubscriptionMap[user] = it.id } + } else { + getSubscription(subId) ?: newSub(key).also { userSubscriptionMap[user] = it.id } + } + } + + override fun updateSubscriptions(keys: Set) { + val uniqueSubscribedAccounts = keys.distinctBy { user(it) } + + val updated = mutableSetOf() + + uniqueSubscribedAccounts.forEach { + val user = user(it) + val newFilters = updateFilter(it, since(it))?.ifEmpty { null } + + findOrCreateSubFor(it).updateFilters(newFilters?.groupByRelay()) + + updated.add(user) + } + + userSubscriptionMap.filter { it.key !in updated }.forEach { + endSub(it.key, it.value) + } + } + + abstract fun updateFilter( + key: T, + since: SincePerRelayMap?, + ): List? + + abstract fun user(key: T): User +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubEoseManager.kt new file mode 100644 index 0000000000..4ed8dbbbc9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubEoseManager.kt @@ -0,0 +1,78 @@ +/** + * 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.service.relayClient.eoseManagers + +import com.vitorpamplona.amethyst.service.relays.EOSERelayList +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * This query type creates only ONE relay subscription. It filters duplicates + * by DistinctById.uniqueId() but it doesn't create a new subscription for each. + * + * All filters are passed as a single sub. + * + * It is ideal for temporary filters, including event, user finding that must be + * disabled after the user is found. + * + * This class keeps EOSEs for as long as possible and + * shares all EOSEs among all users. + */ +abstract class SingleSubEoseManager( + client: NostrClient, + allKeys: () -> Set, + val invalidateAfterEose: Boolean = false, +) : BaseEoseManager(client, allKeys) { + // long term EOSE cache + private val latestEOSEs = EOSERelayList() + + fun since() = latestEOSEs.since() + + open fun newEose( + relay: NormalizedRelayUrl, + time: Long, + ) = latestEOSEs.newEose(relay, time) + + val sub = + requestNewSubscription { time, relayUrl -> + newEose(relayUrl, time) + if (invalidateAfterEose) { + invalidateFilters() + } + } + + override fun updateSubscriptions(keys: Set) { + val uniqueSubscribedAccounts = keys.distinctBy { distinct(it) } + val newFilters = updateFilter(uniqueSubscribedAccounts, since())?.ifEmpty { null } + + sub.updateFilters(newFilters?.groupByRelay()) + } + + abstract fun updateFilter( + keys: List, + since: SincePerRelayMap?, + ): List? + + abstract fun distinct(key: T): Any +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt new file mode 100644 index 0000000000..651cc38dd5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt @@ -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.service.relayClient.eoseManagers + +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay + +/** + * This query type creates only ONE relay subscription and does not + * store any EOSE cache. The EOSE is irrelevant for these types of + * filters + */ +abstract class SingleSubNoEoseCacheEoseManager( + client: NostrClient, + allKeys: () -> Set, + val invalidateAfterEose: Boolean = false, +) : BaseEoseManager(client, allKeys) { + val sub = + requestNewSubscription { time, relayUrl -> + if (invalidateAfterEose) { + invalidateFilters() + } + } + + override fun updateSubscriptions(keys: Set) { + val uniqueSubscribedAccounts = keys.distinctBy { distinct(it) } + val newFilters = updateFilter(uniqueSubscribedAccounts)?.ifEmpty { null } + sub.updateFilters(newFilters?.groupByRelay()) + } + + abstract fun updateFilter(keys: List): List? + + abstract fun distinct(key: T): Any +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/DisplayNotifyMessages.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt similarity index 60% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/DisplayNotifyMessages.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt index 1e63e6988a..dbd28b2dd8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/DisplayNotifyMessages.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt @@ -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,37 +18,52 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.components +package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.actions.NotifyRequestDialog -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyRequestsCache +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import kotlinx.coroutines.flow.map @Composable fun DisplayNotifyMessages( accountViewModel: AccountViewModel, nav: INav, -) { - val openDialogMsg = - accountViewModel.account.transientPaymentRequests.collectAsStateWithLifecycle(null) +) = DisplayNotifyMessages(Amethyst.instance.notifyCoordinator.requests, accountViewModel, nav) - openDialogMsg.value?.firstOrNull()?.let { request -> +@Composable +fun DisplayNotifyMessages( + requests: NotifyRequestsCache, + accountViewModel: AccountViewModel, + nav: INav, +) { + val flow = + remember(accountViewModel) { + requests.transientPaymentRequests.map { + it.filter { it.relayUrl in accountViewModel.account.trustedRelays.flow.value } + } + } + + val openDialogMsg = flow.collectAsStateWithLifecycle(emptySet()) + + openDialogMsg.value.firstOrNull()?.let { request -> NotifyRequestDialog( title = stringRes( id = R.string.payment_required_title, - RelayUrlFormatter.displayUrl(request.relayUrl), + request.relayUrl.displayUrl(), ), textContent = request.description, accountViewModel = accountViewModel, nav = nav, - ) { - accountViewModel.dismissPaymentRequest(request) - } + onDismiss = { requests.dismissPaymentRequest(request) }, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NotifyRequestDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/NotifyRequestDialog.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NotifyRequestDialog.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/NotifyRequestDialog.kt index 540473a612..c87d634923 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NotifyRequestDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/NotifyRequestDialog.kt @@ -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,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.actions +package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -40,8 +40,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size16dp diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt new file mode 100644 index 0000000000..135b9cc747 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt @@ -0,0 +1,39 @@ +/** + * 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.service.relayClient.notifyCommand.model + +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayNotifier + +class NotifyCoordinator( + client: NostrClient, +) { + val requests = NotifyRequestsCache() + + val receiver = + RelayNotifier(client) { message, relay -> + requests.addPaymentRequestIfNew(message, relay.url) + } + + fun destroy() { + receiver.destroy() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequest.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequest.kt new file mode 100644 index 0000000000..74aefabfac --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequest.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +data class NotifyRequest( + val relayUrl: NormalizedRelayUrl, + val description: String, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCache.kt new file mode 100644 index 0000000000..2e7fccc18b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCache.kt @@ -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.service.relayClient.notifyCommand.model + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +class NotifyRequestsCache { + val transientPaymentRequestDismissals: MutableStateFlow> = MutableStateFlow(emptySet()) + val transientPaymentRequests: MutableStateFlow> = MutableStateFlow(emptySet()) + + fun addPaymentRequestIfNew( + description: String, + relayUrl: NormalizedRelayUrl, + ) { + addPaymentRequestIfNew(NotifyRequest(relayUrl, description)) + } + + fun addPaymentRequestIfNew(paymentRequest: NotifyRequest) { + if ( + !this.transientPaymentRequests.value.contains(paymentRequest) && + !this.transientPaymentRequestDismissals.value.contains(paymentRequest) + ) { + this.transientPaymentRequests.value += paymentRequest + } + } + + fun dismissPaymentRequest(request: NotifyRequest) { + if (this.transientPaymentRequests.value.contains(request)) { + this.transientPaymentRequests.update { it - request } + this.transientPaymentRequestDismissals.update { it + request } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt new file mode 100644 index 0000000000..4eeb50ed5b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -0,0 +1,104 @@ +/** + * 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.service.relayClient.reqCommand + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManagerControls +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssembler +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderFilterAssemblyGroup +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssembler +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssembler +import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource.CommunityFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource.HashtagFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource.UserProfileFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssembler +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import kotlinx.coroutines.CoroutineScope + +class RelaySubscriptionsCoordinator( + cache: LocalCache, + client: NostrClient, + scope: CoroutineScope, +) { + // main one: notifications, dms and account settings + val account = AccountFilterAssembler(client) + + // always running, feed assemblers. + val home = HomeFilterAssembler(client) + val chatroomList = ChatroomListFilterAssembler(client) + val video = VideoFilterAssembler(client) + val discovery = DiscoveryFilterAssembler(client) + + // loaders of content that is not yet in the device. + // they are active when looking at events, users, channels. + val channelFinder = ChannelFinderFilterAssemblyGroup(client) + val eventFinder = EventFinderFilterAssembler(client) + val userFinder = UserFinderFilterAssembler(client) + + // active when searching or tagging users. + val search = SearchFilterAssembler(client, scope, cache) + + // active depending on the screen. + val channel = ChannelFilterAssembler(client) + val chatroom = ChatroomFilterAssembler(client) + val community = CommunityFilterAssembler(client) + val thread = ThreadFilterAssembler(client) + val profile = UserProfileFilterAssembler(client) + val hashtags = HashtagFilterAssembler(client) + val geohashes = GeoHashFilterAssembler(client) + + // active when sending zaps via NWC + val nwc = NWCPaymentFilterAssembler(client) + + val all = + listOf( + account, + home, + chatroomList, + video, + discovery, + channelFinder, + eventFinder, + userFinder, + search, + channel, + chatroom, + community, + thread, + profile, + hashtags, + geohashes, + nwc, + ) + + fun destroy() = all.forEach { it.destroy() } + + fun printCounters() = all.forEach { it.printStats() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt new file mode 100644 index 0000000000..479d3c73ea --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt @@ -0,0 +1,61 @@ +/** + * 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.service.relayClient.reqCommand.account + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata.AccountMetadataEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromInboxRelaysManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromRandomRelaysManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient + +// This allows multiple screen to be listening to logged-in accounts. +class AccountQueryState( + val account: Account, + val feedContentStates: AccountFeedContentStates, + val otherAccounts: Set, +) + +/** + * This assembler loads everything eech account needs. + */ +class AccountFilterAssembler( + client: NostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + AccountMetadataEoseManager(client, ::allKeys), + AccountGiftWrapsEoseManager(client, ::allKeys), + AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys), + AccountNotificationsEoseFromRandomRelaysManager(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } + + override fun printStats() = group.forEach { it.printStats() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..cae7379f6b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssemblerSubscription.kt @@ -0,0 +1,44 @@ +/** + * 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.service.relayClient.reqCommand.account + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun AccountFilterAssemblerSubscription(accountViewModel: AccountViewModel) = AccountFilterAssemblerSubscription(accountViewModel, accountViewModel.dataSources().account) + +@Composable +fun AccountFilterAssemblerSubscription( + accountViewModel: AccountViewModel, + dataSource: AccountFilterAssembler, +) { + // different screens get different states + // even if they are tracking the same tag. + val state = + remember(accountViewModel) { + AccountQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.trustedAccounts.value) + } + + KeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountObservers.kt new file mode 100644 index 0000000000..d247179647 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountObservers.kt @@ -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.service.relayClient.reqCommand.account + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.User +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +@Composable +fun observeAccountIsHiddenWord( + account: Account, + word: String, +): State { + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(account, word) { + account.hiddenUsers.flow + .map { word in it.hiddenWords } + .distinctUntilChanged() + } + + return flow.collectAsStateWithLifecycle(false) +} + +@Composable +fun observeAccountIsHiddenUser( + account: Account, + user: User, +): State { + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(account, user) { + account.hiddenUsers.flow + .map { it.hiddenUsers.contains(user.pubkeyHex) || it.spammers.contains(user.pubkeyHex) } + .distinctUntilChanged() + } + + return flow.collectAsStateWithLifecycle(account.isHidden(user)) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt new file mode 100644 index 0000000000..cba60b2596 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt @@ -0,0 +1,84 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +class AccountMetadataEoseManager( + client: NostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun user(key: AccountQueryState) = key.account.userProfile() + + fun relayFlow(query: AccountQueryState) = query.account.outboxRelays.flow + + override fun updateFilter( + key: AccountQueryState, + since: SincePerRelayMap?, + ): List = + relayFlow(key).value.flatMap { + val since = since?.get(it)?.time + listOf( + filterAccountInfoAndListsFromKey(it, user(key).pubkeyHex, since), + filterFollowsAndMutesFromKey(it, user(key).pubkeyHex, since), + filterDraftsAndReportsFromKey(it, user(key).pubkeyHex, since), + filterLastPostsFromKey(it, user(key).pubkeyHex, since), + filterBasicAccountInfoFromKeys(it, key.otherAccounts.minus(key.account.userProfile().pubkeyHex).toList(), since), + ).flatten() + } + + val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: AccountQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.account.scope.launch(Dispatchers.Default) { + relayFlow(key).collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt new file mode 100644 index 0000000000..40aed99a84 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt @@ -0,0 +1,113 @@ +/** + * 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.service.relayClient.reqCommand.account.metadata + +import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState.Companion.APP_SPECIFIC_DATA_D_TAG +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent +import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent +import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent + +val AccountInfoAndListsFromKeyKinds = + listOf( + MetadataEvent.KIND, + ContactListEvent.KIND, + StatusEvent.KIND, + AdvertisedRelayListEvent.KIND, + ChatMessageRelayListEvent.KIND, + SearchRelayListEvent.KIND, + FileServersEvent.KIND, + BlossomServersEvent.KIND, + PrivateOutboxRelayListEvent.KIND, + ) + +val AccountInfoAndListsFromKeyKinds2 = + listOf( + BlockedRelayListEvent.KIND, + TrustedRelayListEvent.KIND, + BroadcastRelayListEvent.KIND, + IndexerRelayListEvent.KIND, + ProxyRelayListEvent.KIND, + HashtagListEvent.KIND, + GeohashListEvent.KIND, + ) + +val AmethystMetadataKinds = listOf(AppSpecificDataEvent.KIND) +val AmethystMetadataTagMapFilter = mapOf("d" to listOf(APP_SPECIFIC_DATA_D_TAG)) + +fun filterAccountInfoAndListsFromKey( + relay: NormalizedRelayUrl, + pubkey: HexKey, + since: Long?, +): List { + if (pubkey.isEmpty()) return emptyList() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = AccountInfoAndListsFromKeyKinds, + authors = listOf(pubkey), + limit = 20, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = AccountInfoAndListsFromKeyKinds2, + authors = listOf(pubkey), + limit = 20, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = AmethystMetadataKinds, + authors = listOf(pubkey), + tags = AmethystMetadataTagMapFilter, + limit = 1, + since = since, + ), + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt new file mode 100644 index 0000000000..6348658b42 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt @@ -0,0 +1,93 @@ +/** + * 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.service.relayClient.reqCommand.account.metadata + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent +import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent + +val BasicAccountInfoKinds = + listOf( + MetadataEvent.KIND, + ContactListEvent.KIND, + AdvertisedRelayListEvent.KIND, + ChatMessageRelayListEvent.KIND, + SearchRelayListEvent.KIND, + FileServersEvent.KIND, + BlossomServersEvent.KIND, + ) + +val BasicAccountInfoKinds2 = + listOf( + BlockedRelayListEvent.KIND, + TrustedRelayListEvent.KIND, + BroadcastRelayListEvent.KIND, + IndexerRelayListEvent.KIND, + ProxyRelayListEvent.KIND, + HashtagListEvent.KIND, + GeohashListEvent.KIND, + ) + +fun filterBasicAccountInfoFromKeys( + relay: NormalizedRelayUrl, + otherAccounts: List?, + since: Long?, +): List { + if (otherAccounts == null || otherAccounts.isEmpty()) return emptyList() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = BasicAccountInfoKinds, + authors = otherAccounts.toList(), + limit = otherAccounts.size * BasicAccountInfoKinds.size, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = BasicAccountInfoKinds2, + authors = otherAccounts.toList(), + limit = otherAccounts.size * BasicAccountInfoKinds2.size, + since = since, + ), + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterDraftsAndReportsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterDraftsAndReportsFromKey.kt new file mode 100644 index 0000000000..c0473e031b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterDraftsAndReportsFromKey.kt @@ -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.service.relayClient.reqCommand.account.metadata + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip37Drafts.DraftEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip56Reports.ReportEvent + +val ReportsAndBookmarksFromKeyKinds = + listOf( + DraftEvent.KIND, + ReportEvent.KIND, + BookmarkListEvent.KIND, + ) + +fun filterDraftsAndReportsFromKey( + relay: NormalizedRelayUrl, + pubkey: HexKey?, + since: Long?, +): List { + if (pubkey == null || pubkey.isEmpty()) return emptyList() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = ReportsAndBookmarksFromKeyKinds, + authors = listOf(pubkey), + since = since, + ), + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterFollowsAndMutesFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterFollowsAndMutesFromKey.kt new file mode 100644 index 0000000000..d1e8a3ea45 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterFollowsAndMutesFromKey.kt @@ -0,0 +1,65 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata + +import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent + +val FollowAndMutesFromKeyKinds = + listOf( + PeopleListEvent.KIND, + FollowListEvent.KIND, + MuteListEvent.KIND, + BadgeProfilesEvent.KIND, + EmojiPackSelectionEvent.KIND, + EphemeralChatListEvent.KIND, + ChannelListEvent.KIND, + ) + +fun filterFollowsAndMutesFromKey( + relay: NormalizedRelayUrl, + pubkey: HexKey, + since: Long?, +): List { + if (pubkey.isEmpty()) return emptyList() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = FollowAndMutesFromKeyKinds, + authors = listOf(pubkey), + limit = 100, + since = since, + ), + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterLastPostsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterLastPostsFromKey.kt new file mode 100644 index 0000000000..00b0ec1eeb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterLastPostsFromKey.kt @@ -0,0 +1,46 @@ +/** + * 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.service.relayClient.reqCommand.account.metadata + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +fun filterLastPostsFromKey( + relay: NormalizedRelayUrl, + pubkey: HexKey, + since: Long?, +): List { + if (pubkey.isEmpty()) return emptyList() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = listOf(pubkey), + limit = 100, + since = since, + ), + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt new file mode 100644 index 0000000000..f9d36d2d32 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt @@ -0,0 +1,96 @@ +/** + * 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.service.relayClient.reqCommand.account.nip01Notifications + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch + +class AccountNotificationsEoseFromInboxRelaysManager( + client: NostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun user(query: AccountQueryState) = query.account.userProfile() + + /** + * Downloads most notifications from the user's own inbox relays. + * But also connects to all the follows relays to check for new notifications that are not in the user's + * own inbox. + */ + override fun updateFilter( + key: AccountQueryState, + since: SincePerRelayMap?, + ): List? = + key.account.notificationRelays.flow.value.flatMap { + filterSummaryNotificationsToPubkey( + relay = it, + pubkey = user(key).pubkeyHex, + since = since?.get(it)?.time ?: TimeUtils.oneWeekAgo(), + ) + filterNotificationsToPubkey( + relay = it, + pubkey = user(key).pubkeyHex, + since = since?.get(it)?.time ?: key.feedContentStates.notifications.lastNoteCreatedAtIfFilled(), + ) + } + + val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: AccountQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.account.scope.launch(Dispatchers.Default) { + key.account.notificationRelays.flow.sample(1000).collectLatest { + invalidateFilters() + } + }, + key.account.scope.launch(Dispatchers.Default) { + key.feedContentStates.notifications.lastNoteCreatedAtWhenFullyLoaded.sample(1000).collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt new file mode 100644 index 0000000000..740d93c9cf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt @@ -0,0 +1,84 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.launch + +class AccountNotificationsEoseFromRandomRelaysManager( + client: NostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun user(query: AccountQueryState) = query.account.userProfile() + + /** + * Downloads most notifications from the user's own inbox relays. + * But also connects to all the follows relays to check for new notifications that are not in the user's + * own inbox. + */ + override fun updateFilter( + key: AccountQueryState, + since: SincePerRelayMap?, + ): List? = + (key.account.followsPerRelay.value.keys - key.account.notificationRelays.flow.value).flatMap { + val since = since?.get(it)?.time ?: TimeUtils.oneDayAgo() + filterJustTheLatestNotificationsToPubkeyFromRandomRelays(it, user(key).pubkeyHex, since) + } + + val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: AccountQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.account.scope.launch(Dispatchers.Default) { + // no need to hurry here. we can wait the app stabilize + key.account.followsPerRelay.debounce(5000).collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt new file mode 100644 index 0000000000..4f75d15218 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt @@ -0,0 +1,175 @@ +/** + * 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.service.relayClient.reqCommand.account.nip01Notifications + +import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent +import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent +import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent +import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip52Calendar.CalendarDateSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.CalendarRSVPEvent +import com.vitorpamplona.quartz.nip52Calendar.CalendarTimeSlotEvent +import com.vitorpamplona.quartz.nip56Reports.ReportEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent + +val SummaryKinds = + listOf( + TextNoteEvent.KIND, + ReactionEvent.KIND, + RepostEvent.KIND, + GenericRepostEvent.KIND, + LnZapEvent.KIND, + ) + +val NotificationsPerKeyKinds = + listOf( + ReportEvent.KIND, + LnZapPaymentResponseEvent.KIND, + ChannelMessageEvent.KIND, + EphemeralChatEvent.KIND, + BadgeAwardEvent.KIND, + PollNoteEvent.KIND, + PublicMessageEvent.KIND, + ) + +val NotificationsPerKeyKinds2 = + listOf( + GitReplyEvent.KIND, + GitIssueEvent.KIND, + GitPatchEvent.KIND, + HighlightEvent.KIND, + CommentEvent.KIND, + CalendarDateSlotEvent.KIND, + CalendarTimeSlotEvent.KIND, + CalendarRSVPEvent.KIND, + InteractiveStoryPrologueEvent.KIND, + InteractiveStorySceneEvent.KIND, + ) + +fun filterSummaryNotificationsToPubkey( + relay: NormalizedRelayUrl, + pubkey: HexKey?, + since: Long?, +): List { + if (pubkey == null || pubkey.isEmpty()) return emptyList() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = SummaryKinds, + tags = mapOf("p" to listOf(pubkey)), + limit = 2000, + since = since, + ), + ), + ) +} + +fun filterNotificationsToPubkey( + relay: NormalizedRelayUrl, + pubkey: HexKey?, + since: Long?, +): List { + if (pubkey == null || pubkey.isEmpty()) return emptyList() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = NotificationsPerKeyKinds, + tags = mapOf("p" to listOf(pubkey)), + limit = 50, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = NotificationsPerKeyKinds2, + tags = mapOf("p" to listOf(pubkey)), + limit = 100, + since = since, + ), + ), + ) +} + +fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays( + relay: NormalizedRelayUrl, + pubkey: HexKey?, + since: Long?, +): List { + if (pubkey == null || pubkey.isEmpty()) return emptyList() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = SummaryKinds, + tags = mapOf("p" to listOf(pubkey)), + limit = 10, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = NotificationsPerKeyKinds, + tags = mapOf("p" to listOf(pubkey)), + limit = 5, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = NotificationsPerKeyKinds2, + tags = mapOf("p" to listOf(pubkey)), + limit = 5, + since = since, + ), + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt new file mode 100644 index 0000000000..79928cd57d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt @@ -0,0 +1,79 @@ +/** + * 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.service.relayClient.reqCommand.account.nip59GiftWraps + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +class AccountGiftWrapsEoseManager( + client: NostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun user(key: AccountQueryState) = key.account.userProfile() + + override fun updateFilter( + key: AccountQueryState, + since: SincePerRelayMap?, + ): List = + key.account.dmRelays.flow.value.flatMap { relay -> + filterGiftWrapsToPubkey( + relay = relay, + pubkey = user(key).pubkeyHex, + since = since?.get(relay)?.time, + ) + } + + val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: AccountQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.account.scope.launch(Dispatchers.Default) { + key.account.dmRelays.flow.collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/FilterGiftWrapsToPubkey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/FilterGiftWrapsToPubkey.kt new file mode 100644 index 0000000000..a9b0765a22 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/FilterGiftWrapsToPubkey.kt @@ -0,0 +1,48 @@ +/** + * 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.service.relayClient.reqCommand.account.nip59GiftWraps + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +fun filterGiftWrapsToPubkey( + relay: NormalizedRelayUrl, + pubkey: HexKey?, + since: Long?, +): List { + if (pubkey == null || pubkey.isEmpty()) return emptyList() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(GiftWrapEvent.KIND), + tags = mapOf("p" to listOf(pubkey)), + since = since?.minus(TimeUtils.twoDays()), + ), + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt new file mode 100644 index 0000000000..797d2cccec --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt @@ -0,0 +1,54 @@ +/** + * 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.service.relayClient.reqCommand.channel + +import com.vitorpamplona.amethyst.model.Channel +import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.mixChatsLive.ChannelMetadataAndLiveActivityWatcherSubAssembler +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats.ChannelLoaderSubAssembler +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient + +// This allows multiple screen to be listening to tags, even the same tag +class ChannelFinderQueryState( + val channel: Channel, +) + +class ChannelFinderFilterAssemblyGroup( + client: NostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + // this is a constantly rotating filter, better to keep it isolated + ChannelLoaderSubAssembler(client, ::allKeys), + // Later we could switch these and use one subscription for each query + // ChannelMetadataWatcherSubAssembler(client, ::allKeys), + // LiveActivityWatcherSubAssembly(client, ::allKeys) + ChannelMetadataAndLiveActivityWatcherSubAssembler(client, ::allKeys), + ) + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun invalidateKeys() = invalidateFilters() + + override fun destroy() = group.forEach { it.destroy() } + + override fun printStats() = group.forEach { it.printStats() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblySubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblySubscription.kt new file mode 100644 index 0000000000..eebcea1664 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblySubscription.kt @@ -0,0 +1,48 @@ +/** + * 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.service.relayClient.reqCommand.channel + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.model.Channel +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun ChannelFinderFilterAssemblerSubscription( + channel: Channel, + accountViewModel: AccountViewModel, +) = ChannelFinderFilterAssemblerSubscription(channel, accountViewModel.dataSources().channelFinder) + +@Composable +fun ChannelFinderFilterAssemblerSubscription( + channel: Channel, + dataSource: ChannelFinderFilterAssemblyGroup, +) { + // different screens get different states + // even if they are tracking the same tag. + val state = + remember(channel) { + ChannelFinderQueryState(channel) + } + + KeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelObservers.kt new file mode 100644 index 0000000000..7e83e71498 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelObservers.kt @@ -0,0 +1,132 @@ +/** + * 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.service.relayClient.reqCommand.channel + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.model.Channel +import com.vitorpamplona.amethyst.model.ChannelState +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onStart + +@Composable +fun observeChannel( + baseChannel: Channel, + accountViewModel: AccountViewModel, +): State { + ChannelFinderFilterAssemblerSubscription(baseChannel, accountViewModel) + + return baseChannel + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() +} + +@OptIn(ExperimentalCoroutinesApi::class) +@Composable +fun observeChannelNoteAuthors( + baseChannel: Channel, + accountViewModel: AccountViewModel, +): State> { + ChannelFinderFilterAssemblerSubscription(baseChannel, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(baseChannel) { + baseChannel + .flow() + .notes.stateFlow + .mapLatest { + it.channel.notes + .mapNotNull { key, value -> value.author } + .toSet() + .toImmutableList() + }.onStart { + emit( + baseChannel.notes + .mapNotNull { key, value -> value.author } + .toSet() + .toImmutableList(), + ) + }.distinctUntilChanged() + .flowOn(Dispatchers.Default) + } + + return flow.collectAsStateWithLifecycle(persistentListOf()) +} + +@OptIn(ExperimentalCoroutinesApi::class) +@Composable +fun observeChannelPicture( + baseChannel: PublicChatChannel, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in the metadata of this user. + ChannelFinderFilterAssemblerSubscription(baseChannel, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(baseChannel) { + baseChannel + .flow() + .metadata.stateFlow + .mapLatest { (it.channel as? PublicChatChannel)?.profilePicture() } + .distinctUntilChanged() + } + + return flow.collectAsStateWithLifecycle(baseChannel.profilePicture()) +} + +@OptIn(ExperimentalCoroutinesApi::class) +@Composable +fun observeChannelInfo( + baseChannel: LiveActivitiesChannel, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in the metadata of this user. + ChannelFinderFilterAssemblerSubscription(baseChannel, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(baseChannel) { + baseChannel + .flow() + .metadata.stateFlow + .mapLatest { (it.channel as? LiveActivitiesChannel)?.info } + .distinctUntilChanged() + } + + return flow.collectAsStateWithLifecycle(baseChannel.info) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/mixChatsLive/ChannelMetadataAndLiveActivityWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/mixChatsLive/ChannelMetadataAndLiveActivityWatcherSubAssembler.kt new file mode 100644 index 0000000000..541605b070 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/mixChatsLive/ChannelMetadataAndLiveActivityWatcherSubAssembler.kt @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.mixChatsLive + +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats.filterChannelMetadataUpdatesById +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip53LiveActivities.filterLiveStreamUpdatesByAddress +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.utils.mapOfSet + +/** + * This assembler observes modifications to the LiveActivity root events + * since they are replaceable and updates to the public chat metadata events. + * + * They are all cramped as multiple filters in a single subscription with + * only one EOSE for everybody. + */ +class ChannelMetadataAndLiveActivityWatcherSubAssembler( + client: NostrClient, + allKeys: () -> Set, +) : SingleSubEoseManager(client, allKeys) { + override fun updateFilter( + keys: List, + since: SincePerRelayMap?, + ): List { + val perRelayPublicChannelFilter = + mapOfSet { + keys.forEach { key -> + if (key.channel is PublicChatChannel) { + key.channel.relays().forEach { + add(it, key.channel) + } + } + } + } + + val perRelayLiveActivityFilter = + mapOfSet { + keys.forEach { key -> + if (key.channel is LiveActivitiesChannel) { + key.channel.relays().forEach { + add(it, key.channel) + } + } + } + } + + return perRelayPublicChannelFilter.flatMap { (relay, channels) -> + filterChannelMetadataUpdatesById(relay, channels.toList(), since?.get(relay)?.time) + } + + perRelayLiveActivityFilter.flatMap { (relay, channels) -> + filterLiveStreamUpdatesByAddress(relay, channels.toList(), since?.get(relay)?.time) + } + } + + override fun distinct(key: ChannelFinderQueryState) = key.channel +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelLoaderSubAssembler.kt new file mode 100644 index 0000000000..5931a339c4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelLoaderSubAssembler.kt @@ -0,0 +1,44 @@ +/** + * 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.service.relayClient.reqCommand.channel.nip28PublicChats + +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +/** + * This assembler observes loads missing public chats when needed. + * + * It is a fast rotating filter that tends to zero as new events arrive. + * Because of that, we isolate to avoid interrupting the EOSE of filters + * with long loading times. + * + * This is one filter for everybody. + */ +class ChannelLoaderSubAssembler( + client: NostrClient, + allKeys: () -> Set, +) : SingleSubNoEoseCacheEoseManager(client, allKeys, invalidateAfterEose = true) { + override fun updateFilter(keys: List): List? = filterMissingChannelsById(keys) + + override fun distinct(key: ChannelFinderQueryState) = key.channel +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelMetadataWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelMetadataWatcherSubAssembler.kt new file mode 100644 index 0000000000..f1a15eea27 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelMetadataWatcherSubAssembler.kt @@ -0,0 +1,51 @@ +/** + * 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.service.relayClient.reqCommand.channel.nip28PublicChats + +import com.vitorpamplona.amethyst.model.Channel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +class ChannelMetadataWatcherSubAssembler( + client: NostrClient, + allKeys: () -> Set, +) : PerUniqueIdEoseManager(client, allKeys) { + override fun updateFilter( + key: ChannelFinderQueryState, + since: SincePerRelayMap?, + ): List = + if (key.channel is PublicChatChannel) { + key.channel.relays().flatMap { + filterChannelMetadataUpdatesById(it, listOf(key.channel), since?.get(it)?.time) + } + } else { + emptyList() + } + + /** + * Only one key per channel. + */ + override fun id(key: ChannelFinderQueryState) = key.channel +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt new file mode 100644 index 0000000000..8841556149 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt @@ -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.service.relayClient.reqCommand.channel.nip28PublicChats + +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.utils.mapOfSet + +val filterMissingPublicChannelsByIdKinds = listOf(ChannelCreateEvent.KIND) + +fun filterMissingChannelsById(keys: List): List { + val relayPerChannel = + mapOfSet { + keys.forEach { key -> + if (key.channel is PublicChatChannel && key.channel.event == null) { + key.channel.relays().forEach { + add(it, key.channel.idHex) + } + } else { + null + } + } + } + + if (relayPerChannel.isEmpty()) return emptyList() + + return relayPerChannel.mapNotNull { + if (it.value.isEmpty()) { + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = filterMissingPublicChannelsByIdKinds, + ids = it.value.sorted(), + ), + ) + } else { + null + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataUpdatesById.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataUpdatesById.kt new file mode 100644 index 0000000000..86c876a9ea --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataUpdatesById.kt @@ -0,0 +1,46 @@ +/** + * 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.service.relayClient.reqCommand.channel.nip28PublicChats + +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent + +val channelMetadataKinds = listOf(ChannelMetadataEvent.KIND) + +fun filterChannelMetadataUpdatesById( + relay: NormalizedRelayUrl, + channels: List, + since: Long?, +): List = + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = channelMetadataKinds, + tags = mapOf("e" to channels.map { it.idHex }), + since = since, + ), + ), + ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip53LiveActivities/FilterLiveStreamUpdatesByAddress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip53LiveActivities/FilterLiveStreamUpdatesByAddress.kt new file mode 100644 index 0000000000..07f98233bd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip53LiveActivities/FilterLiveStreamUpdatesByAddress.kt @@ -0,0 +1,47 @@ +/** + * 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.service.relayClient.reqCommand.channel.nip53LiveActivities + +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent + +fun filterLiveStreamUpdatesByAddress( + relay: NormalizedRelayUrl, + channels: List, + since: Long?, +): List { + // this runs a cross product of all ds and for pubkeys, but we are assuming they are quite unique. + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(LiveActivitiesEvent.KIND), + tags = mapOf("d" to channels.map { it.address.dTag }), + authors = channels.map { it.address.pubKeyHex }, + since = since, + ), + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip53LiveActivities/LiveActivityWatcherSubAssembly.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip53LiveActivities/LiveActivityWatcherSubAssembly.kt new file mode 100644 index 0000000000..739c118b60 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip53LiveActivities/LiveActivityWatcherSubAssembly.kt @@ -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.service.relayClient.reqCommand.channel.nip53LiveActivities + +import com.vitorpamplona.amethyst.model.Channel +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +/** + * This assembler observes modifications to the LiveActivity root events + * since they are replaceable. + */ +class LiveActivityWatcherSubAssembly( + client: NostrClient, + allKeys: () -> Set, +) : PerUniqueIdEoseManager(client, allKeys) { + override fun updateFilter( + key: ChannelFinderQueryState, + since: SincePerRelayMap?, + ): List = + if (key.channel is LiveActivitiesChannel) { + key.channel.relays().flatMap { + filterLiveStreamUpdatesByAddress(it, listOf(key.channel), since?.get(it)?.time) + } + } else { + emptyList() + } + + /** + * Only one key per channel. + */ + override fun id(key: ChannelFinderQueryState) = key.channel +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt new file mode 100644 index 0000000000..c9a9685cdd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.NoteEventLoaderSubAssembler +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers.EventWatcherSubAssembler +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient + +// This allows multiple screen to be listening to tags, even the same tag +class EventFinderQueryState( + val note: Note, + val account: Account, +) + +class EventFinderFilterAssembler( + client: NostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + NoteEventLoaderSubAssembler(client, ::allKeys), + EventWatcherSubAssembler(client, ::allKeys), + ) + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun invalidateKeys() = invalidateFilters() + + override fun destroy() = group.forEach { it.destroy() } + + override fun printStats() = group.forEach { it.printStats() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPrivateFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssemblerSubscription.kt similarity index 54% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPrivateFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssemblerSubscription.kt index 1be1e5b159..d0ffe095e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPrivateFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssemblerSubscription.kt @@ -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,33 +18,33 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -class BookmarkPrivateFeedFilter( - val account: Account, -) : FeedFilter() { - override fun feedKey(): String = account.userProfile().latestBookmarkList?.id ?: "" +@Composable +fun EventFinderFilterAssemblerSubscription( + note: Note, + accountViewModel: AccountViewModel, +) = EventFinderFilterAssemblerSubscription(note, accountViewModel.account, accountViewModel.dataSources().eventFinder) - override fun feed(): List { - val bookmarks = account.userProfile().latestBookmarkList +@Composable +fun EventFinderFilterAssemblerSubscription( + note: Note, + account: Account, + dataSource: EventFinderFilterAssembler, +) { + // different screens get different states + // even if they are tracking the same tag. + val state = + remember(note, account) { + EventFinderQueryState(note, account) + } - if (!account.isWriteable()) return emptyList() - - val privateTags = bookmarks?.cachedPrivateTags() ?: return emptyList() - - return privateTags - .mapNotNull { - if (it.size > 1 && it[0] == "e") { - LocalCache.checkGetOrCreateNote(it[1]) - } else if (it.size > 1 && it[0] == "a") { - LocalCache.checkGetOrCreateAddressableNote(it[1]) - } else { - null - } - }.reversed() - } + KeyDataSourceSubscription(state, dataSource) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt new file mode 100644 index 0000000000..960018f896 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt @@ -0,0 +1,358 @@ +/** + * 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.service.relayClient.reqCommand.event + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.sample + +@Composable +fun observeNote( + note: Note, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + return note + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() +} + +@Suppress("UNCHECKED_CAST") +@OptIn(ExperimentalCoroutinesApi::class) +@Composable +fun observeNoteEvent( + note: Note, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(note) { + note + .flow() + .metadata.stateFlow + .mapLatest { it.note.event as? T? } + } + + return flow.collectAsStateWithLifecycle(note.event as? T?) +} + +@OptIn(ExperimentalCoroutinesApi::class) +@Composable +fun observeNoteAndMap( + note: Note, + accountViewModel: AccountViewModel, + map: (Note) -> T, +): State { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note, accountViewModel) + + val flow = + remember(note) { + note + .flow() + .metadata.stateFlow + .mapLatest { map(it.note) } + .distinctUntilChanged() + .flowOn(Dispatchers.Default) + } + + // Subscribe in the LocalCache for changes that arrive in the device + return flow.collectAsStateWithLifecycle(map(note)) +} + +@OptIn(ExperimentalCoroutinesApi::class) +@Suppress("UNCHECKED_CAST") +@Composable +fun observeNoteEventAndMap( + note: Note, + accountViewModel: AccountViewModel, + map: (T) -> U, +): State { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(note) { + note + .flow() + .metadata.stateFlow + .mapLatest { (it.note.event as? T)?.let { map(it) } } + .distinctUntilChanged() + .flowOn(Dispatchers.Default) + } + + // Subscribe in the LocalCache for changes that arrive in the device + return flow.collectAsStateWithLifecycle((note.event as? T)?.let { map(it) }) +} + +@OptIn(ExperimentalCoroutinesApi::class) +@Composable +fun observeNoteHasEvent( + note: Note, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(note) { + note + .flow() + .metadata.stateFlow + .mapLatest { it.note.event != null } + .distinctUntilChanged() + } + + return flow.collectAsStateWithLifecycle(note.event != null) +} + +@Composable +fun observeNoteReplies( + note: Note, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + return note + .flow() + .replies.stateFlow + .collectAsStateWithLifecycle() +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@Composable +fun observeNoteReplyCount( + note: Note, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(note) { + note + .flow() + .replies.stateFlow + .sample(200) + .mapLatest { it.note.replies.size } + .distinctUntilChanged() + } + + return flow.collectAsStateWithLifecycle(note.replies.size) +} + +@Composable +fun observeNoteReactions( + note: Note, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + return note + .flow() + .reactions.stateFlow + .collectAsStateWithLifecycle() +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@Composable +fun observeNoteReactionCount( + note: Note, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(note) { + note + .flow() + .reactions.stateFlow + .sample(200) + .mapLatest { it.note.countReactions() } + .distinctUntilChanged() + .flowOn(Dispatchers.Default) + } + + // Subscribe in the LocalCache for changes that arrive in the device + return flow.collectAsStateWithLifecycle(note.countReactions()) +} + +@Composable +fun observeNoteZaps( + note: Note, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + return note + .flow() + .zaps.stateFlow + .collectAsStateWithLifecycle() +} + +@Composable +fun observeNoteReposts( + note: Note, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + return note + .flow() + .boosts.stateFlow + .collectAsStateWithLifecycle() +} + +@OptIn(ExperimentalCoroutinesApi::class) +@Composable +fun observeNoteRepostsBy( + note: Note, + user: User, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(note) { + note + .flow() + .boosts.stateFlow + .mapLatest { it.note.isBoostedBy(user) } + .distinctUntilChanged() + .flowOn(Dispatchers.Default) + } + + return flow.collectAsStateWithLifecycle(note.isBoostedBy(user)) +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@Composable +fun observeNoteRepostCount( + note: Note, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(note) { + note + .flow() + .boosts.stateFlow + .sample(200) + .mapLatest { note.boosts.size } + .distinctUntilChanged() + } + + return flow.collectAsStateWithLifecycle(note.boosts.size) +} + +@Composable +fun observeNoteReferences( + note: Note, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(note) { + combine( + note.flow().zaps.stateFlow, + note.flow().boosts.stateFlow, + note.flow().reactions.stateFlow, + ) { zapState, boostState, reactionState -> + zapState.note.hasZapsBoostsOrReactions() + }.distinctUntilChanged() + } + + return flow.collectAsStateWithLifecycle(note.hasZapsBoostsOrReactions()) +} + +@Composable +fun observeNoteOts( + note: Note, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + return note + .flow() + .ots + .stateFlow + .collectAsStateWithLifecycle() +} + +@Composable +fun observeNoteEdits( + note: Note, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + return note + .flow() + .edits + .stateFlow + .collectAsStateWithLifecycle() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingAddressables.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingAddressables.kt new file mode 100644 index 0000000000..5beab5ac6f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingAddressables.kt @@ -0,0 +1,124 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders + +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.utils.mapOfSet + +fun potentialRelaysToFindAddress(note: AddressableNote): Set { + val set = mutableSetOf() + + set.addAll(LocalCache.relayHints.hintsForAddress(note.idHex)) + + LocalCache.getAnyChannel(note)?.relays()?.let { set.addAll(it) } + + note.replyTo?.map { parentNote -> + set.addAll(parentNote.relays) + + LocalCache.getAnyChannel(parentNote)?.relays()?.let { set.addAll(it) } + + parentNote.author?.inboxRelays()?.let { set.addAll(it) } + } + + note.replies.map { childNote -> + set.addAll(childNote.relays) + + LocalCache.getAnyChannel(childNote)?.relays()?.let { set.addAll(it) } + + childNote.author?.outboxRelays()?.let { set.addAll(it) } + } + + note.reactions.map { reactionType -> + reactionType.value.forEach { childNote -> + set.addAll(childNote.relays) + childNote.author?.outboxRelays()?.let { set.addAll(it) } + } + } + + note.boosts.map { childNote -> + set.addAll(childNote.relays) + childNote.author?.outboxRelays()?.let { set.addAll(it) } + } + + return set +} + +fun filterMissingAddressables(keys: List): List? { + val addressesPerRelay = + mapOfSet { + keys.forEach { key -> + val default = key.account.followPlusAllMine.flow.value + if (key.note is AddressableNote && key.note.event == null) { + potentialRelaysToFindAddress(key.note).ifEmpty { default }.forEach { relayUrl -> + add(relayUrl, key.note.address) + } + } + + // loads threading that is event-based + key.note.replyTo?.forEach { note -> + if (note is AddressableNote && note.event == null) { + potentialRelaysToFindAddress(note).ifEmpty { default }.forEach { relayUrl -> + add(relayUrl, note.address) + } + } + } + } + } + + return filterMissingAddressables(addressesPerRelay) +} + +fun filterMissingAddressables(missingAddressables: Map>): List { + if (missingAddressables.isEmpty()) return emptyList() + + return missingAddressables.flatMap { relayEntry -> + relayEntry.value.map { address -> + if (address.kind < 25000 && address.dTag.isBlank()) { + RelayBasedFilter( + relay = relayEntry.key, + filter = + Filter( + kinds = listOf(address.kind), + authors = listOf(address.pubKeyHex), + limit = 1, + ), + ) + } else { + RelayBasedFilter( + relay = relayEntry.key, + filter = + Filter( + kinds = listOf(address.kind), + tags = mapOf("d" to listOf(address.dTag)), + authors = listOf(address.pubKeyHex), + limit = 1, + ), + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt new file mode 100644 index 0000000000..b8f6e1745f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt @@ -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.service.relayClient.reqCommand.event.loaders + +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.mapOfSet + +fun potentialRelaysToFindEvent(note: Note): Set { + val set = mutableSetOf() + + set.addAll(LocalCache.relayHints.hintsForEvent(note.idHex)) + + LocalCache.getAnyChannel(note)?.relays()?.let { set.addAll(it) } + + note.replyTo?.map { parentNote -> + set.addAll(parentNote.relays) + + LocalCache.getAnyChannel(parentNote)?.relays()?.let { set.addAll(it) } + + parentNote.author?.inboxRelays()?.let { set.addAll(it) } + } + + note.replies.map { childNote -> + set.addAll(childNote.relays) + + LocalCache.getAnyChannel(childNote)?.relays()?.let { set.addAll(it) } + + childNote.author?.outboxRelays()?.let { set.addAll(it) } + } + + note.reactions.map { reactionType -> + reactionType.value.forEach { childNote -> + set.addAll(childNote.relays) + childNote.author?.outboxRelays()?.let { set.addAll(it) } + } + } + + note.boosts.map { childNote -> + set.addAll(childNote.relays) + childNote.author?.outboxRelays()?.let { set.addAll(it) } + } + + return set +} + +fun filterMissingEvents(keys: List): List? { + val eventsPerRelay = + mapOfSet { + keys.forEach { key -> + val default = key.account.followPlusAllMine.flow.value + + if (key.note !is AddressableNote && key.note.event == null) { + potentialRelaysToFindEvent(key.note).ifEmpty { default }.forEach { relayUrl -> + add(relayUrl, key.note.idHex) + } + } + + // loads threading that is event-based + key.note.replyTo?.forEach { note -> + if (note !is AddressableNote && note.event == null) { + potentialRelaysToFindEvent(note).ifEmpty { default }.forEach { relayUrl -> + add(relayUrl, note.idHex) + } + } + } + } + } + + return filterMissingEvents(eventsPerRelay) +} + +fun filterMissingEvents(missingEventIds: Map>): List { + if (missingEventIds.isEmpty()) return emptyList() + + return missingEventIds.mapNotNull { + if (it.value.isNotEmpty()) { + RelayBasedFilter( + relay = it.key, + filter = Filter(ids = it.value.sorted()), + ) + } else { + null + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/NoteEventLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/NoteEventLoaderSubAssembler.kt new file mode 100644 index 0000000000..715a6f48eb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/NoteEventLoaderSubAssembler.kt @@ -0,0 +1,38 @@ +/** + * 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.service.relayClient.reqCommand.event.loaders + +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient + +class NoteEventLoaderSubAssembler( + client: NostrClient, + allKeys: () -> Set, +) : SingleSubNoEoseCacheEoseManager(client, allKeys, invalidateAfterEose = true) { + override fun updateFilter(keys: List) = + listOfNotNull( + filterMissingEvents(keys), + filterMissingAddressables(keys), + ).flatten() + + override fun distinct(key: EventFinderQueryState) = key.note +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt new file mode 100644 index 0000000000..36cff0b093 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt @@ -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.service.relayClient.reqCommand.event.watchers + +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState +import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.ammolite.relays.filters.MutableTime +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class EventWatcherSubAssembler( + client: NostrClient, + allKeys: () -> Set, +) : SingleSubEoseManager(client, allKeys) { + var lastNotesOnFilter = emptyList() + var latestEOSEs: EOSEAccountFast = EOSEAccountFast(10000) + + override fun newEose( + relay: NormalizedRelayUrl, + time: Long, + ) { + lastNotesOnFilter.forEach { + latestEOSEs.newEose(it, relay, time) + } + super.newEose(relay, time) + } + + override fun updateFilter( + key: List, + since: SincePerRelayMap?, + ): List? { + if (key.isEmpty()) { + return null + } + + lastNotesOnFilter = key.map { it.note } + + return groupByRelayPresence(lastNotesOnFilter, latestEOSEs) + .map { group -> + if (group.isNotEmpty()) { + val addressables = group.filterIsInstance() + val events = group.mapNotNull { if (it !is AddressableNote) it else null } + + listOfNotNull( + filterRepliesAndReactionsToNotes(events, findMinimumEOSEs(events, latestEOSEs)), + filterRepliesAndReactionsToAddresses(addressables, findMinimumEOSEs(addressables, latestEOSEs)), + ).flatten() + } else { + emptyList() + } + }.flatten() + } + + override fun distinct(key: EventFinderQueryState) = key.note + + fun groupByRelayPresence( + notes: Iterable, + eoseCache: EOSEAccountFast, + ): Collection> = + notes + .groupBy { eoseCache.since(it)?.keys?.hashCode() } + .values + .map { + // important to keep in order otherwise the Relay thinks the filter has changed and we REQ again + it.sortedBy { it.idHex } + } + + fun findMinimumEOSEs( + notes: List, + eoseCache: EOSEAccountFast, + ): SincePerRelayMap { + val minLatestEOSEs = mutableMapOf() + + notes.forEach { note -> + eoseCache.since(note)?.forEach { + val minEose = minLatestEOSEs[it.key] + if (minEose == null) { + minLatestEOSEs.put(it.key, it.value.copy()) + } else { + minEose.updateIfOlder(it.value.time) + } + } + } + + return minLatestEOSEs + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt new file mode 100644 index 0000000000..b010546033 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt @@ -0,0 +1,128 @@ +/** + * 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.service.relayClient.reqCommand.event.watchers + +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip56Reports.ReportEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.utils.mapOfSet + +val RepliesAndReactionsToAddressesKinds1 = + listOf( + TextNoteEvent.KIND, + ReactionEvent.KIND, + RepostEvent.KIND, + GenericRepostEvent.KIND, + ReportEvent.KIND, + LnZapEvent.KIND, + PollNoteEvent.KIND, + ) + +val PostsAndChatMessagesToAddresses = + listOf( + CommunityPostApprovalEvent.KIND, + LiveActivitiesChatMessageEvent.KIND, + ) + +val DeletionKindList = + listOf( + DeletionEvent.KIND, + ) + +val TextNoteKindList = listOf(TextNoteEvent.KIND) + +fun filterRepliesAndReactionsToAddresses( + keys: List, + since: SincePerRelayMap?, +): List? { + if (keys.isEmpty()) return null + + val notesPerRelay = + mapOfSet { + keys.forEach { + it.relayUrlsForReactions().forEach { relay -> + add(relay, it.address().toValue()) + } + } + } + + return notesPerRelay.flatMap { + val since = since?.get(it.key)?.time + val sortedList = it.value.sorted() + val relay = it.key + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = RepliesAndReactionsToAddressesKinds1, + tags = mapOf("a" to sortedList), + since = since, + // Max amount of "replies" to download on a specific event. + limit = 1000, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = PostsAndChatMessagesToAddresses, + tags = mapOf("a" to sortedList), + since = since, + // Max amount of "replies" to download on a specific event. + limit = 100, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = DeletionKindList, + tags = mapOf("a" to sortedList), + since = since, + // Max amount of "replies" to download on a specific event. + limit = 10, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = TextNoteKindList, + tags = mapOf("q" to sortedList), + since = since, + limit = 1000, + ), + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt new file mode 100644 index 0000000000..a6c6d05949 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt @@ -0,0 +1,122 @@ +/** + * 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.service.relayClient.reqCommand.event.watchers + +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent +import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent +import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent +import com.vitorpamplona.quartz.nip56Reports.ReportEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent +import com.vitorpamplona.quartz.utils.mapOfSet + +val RepliesAndReactionsKinds = + listOf( + TextNoteEvent.KIND, + ReactionEvent.KIND, + RepostEvent.KIND, + GenericRepostEvent.KIND, + ReportEvent.KIND, + LnZapEvent.KIND, + PollNoteEvent.KIND, + OtsEvent.KIND, + TextNoteModificationEvent.KIND, + GitReplyEvent.KIND, + ) + +val RepliesAndReactionsKinds2 = + listOf( + DeletionEvent.KIND, + NIP90ContentDiscoveryResponseEvent.KIND, + NIP90StatusEvent.KIND, + TorrentCommentEvent.KIND, + ) + +fun filterRepliesAndReactionsToNotes( + events: List, + since: SincePerRelayMap?, +): List? { + if (events.isEmpty()) return null + + val perRelayEventIds = + mapOfSet { + events.forEach { note -> + note.relayUrlsForReactions().forEach { relay -> + add(relay, note.idHex) + } + } + } + + return perRelayEventIds.flatMap { + val since = since?.get(it.key)?.time + val sortedList = it.value.sorted() + val relay = it.key + if (sortedList.isNotEmpty()) { + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = RepliesAndReactionsKinds, + tags = mapOf("e" to sortedList), + since = since, + // Max amount of "replies" to download on a specific event. + limit = 1000, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = RepliesAndReactionsKinds2, + tags = mapOf("e" to sortedList), + since = since, + limit = 100, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(TextNoteEvent.KIND), + tags = mapOf("q" to sortedList), + since = since, + limit = 1000, + ), + ), + ) + } else { + emptyList() + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/FilterNWCPaymentsFromRequests.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/FilterNWCPaymentsFromRequests.kt new file mode 100644 index 0000000000..140ca7ca05 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/FilterNWCPaymentsFromRequests.kt @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent + +fun filterNWCPaymentsFromRequests( + serviceKeys: Set, + paymentRequests: Set, + fromUsers: Set, +): Filter = + Filter( + kinds = listOf(LnZapPaymentResponseEvent.KIND), + authors = serviceKeys.sorted(), + tags = + mapOf( + "e" to paymentRequests.sorted(), + "p" to fromUsers.sorted(), + ), + ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCFinderFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCFinderFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..fbb130e337 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCFinderFilterAssemblerSubscription.kt @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc + +import android.annotation.SuppressLint +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent + +@SuppressLint("StateFlowValueCalledInComposition") +@Composable +fun NWCFinderFilterAssemblerSubscription( + note: Note, + accountViewModel: AccountViewModel, +) = NWCFinderFilterAssemblerSubscription( + note, + accountViewModel.dataSources().nwc, +) + +@Composable +fun NWCFinderFilterAssemblerSubscription( + note: Note, + dataSource: NWCPaymentFilterAssembler, +) { + // different screens get different states + // even if they are tracking the same tag. + val states = + remember(note) { + val zapPaymentRequestNote = note + (zapPaymentRequestNote.event as? LnZapPaymentRequestEvent)?.let { noteEvent -> + noteEvent.walletServicePubKey()?.let { serviceId -> + zapPaymentRequestNote.relays.map { + NWCPaymentQueryState( + fromServiceHex = serviceId, + toUserHex = noteEvent.pubKey, + replyingToHex = noteEvent.id, + relay = it, + ) + } + } + } + } + + states?.forEach { + KeyDataSourceSubscription(it, dataSource) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt new file mode 100644 index 0000000000..71ca89d703 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt @@ -0,0 +1,51 @@ +/** + * 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.service.relayClient.reqCommand.nwc + +import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +// This allows multiple screen to be listening to tags, even the same tag +class NWCPaymentQueryState( + val fromServiceHex: HexKey, + val toUserHex: HexKey, + val replyingToHex: HexKey, + val relay: NormalizedRelayUrl, +) + +class NWCPaymentFilterAssembler( + client: NostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + NWCPaymentWatcherSubAssembler(client, ::allKeys), + ) + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun invalidateKeys() = invalidateFilters() + + override fun destroy() = group.forEach { it.destroy() } + + override fun printStats() = group.forEach { it.printStats() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentWatcherSubAssembler.kt new file mode 100644 index 0000000000..98e15761e8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentWatcherSubAssembler.kt @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc + +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +class NWCPaymentWatcherSubAssembler( + client: NostrClient, + allKeys: () -> Set, +) : SingleSubNoEoseCacheEoseManager(client, allKeys) { + override fun updateFilter(keys: List): List? { + if (keys.isEmpty()) return null + + return keys.groupBy { it.relay }.map { relayGroup -> + val fromAuthors = relayGroup.value.mapTo(mutableSetOf()) { it.fromServiceHex } + val replyingToPayments = relayGroup.value.mapTo(mutableSetOf()) { it.replyingToHex } + val aboutUsers = relayGroup.value.mapTo(mutableSetOf()) { it.toUserHex } + + if (fromAuthors.isEmpty() || replyingToPayments.isEmpty()) return null + + RelayBasedFilter( + relay = relayGroup.key, + filter = filterNWCPaymentsFromRequests(fromAuthors, replyingToPayments, aboutUsers), + ) + } + } + + override fun distinct(key: NWCPaymentQueryState) = key.replyingToHex +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt new file mode 100644 index 0000000000..957107df0c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt @@ -0,0 +1,54 @@ +/** + * 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.service.relayClient.reqCommand.user + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders.UserLoaderSubAssembler +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserReportsSubAssembler +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserWatcherSubAssembler +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient + +// This allows multiple screen to be listening to tags, even the same tag +class UserFinderQueryState( + val user: User, + val account: Account, +) + +class UserFinderFilterAssembler( + client: NostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + UserLoaderSubAssembler(client, ::allKeys), + UserWatcherSubAssembler(client, ::allKeys), + UserReportsSubAssembler(client, ::allKeys), + ) + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun invalidateKeys() = invalidateFilters() + + override fun destroy() = group.forEach { it.destroy() } + + override fun printStats() = group.forEach { it.printStats() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..7e1450331e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt @@ -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.service.relayClient.reqCommand.user + +import android.annotation.SuppressLint +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@SuppressLint("StateFlowValueCalledInComposition") +@Composable +fun UserFinderFilterAssemblerSubscription( + user: User, + accountViewModel: AccountViewModel, +) = UserFinderFilterAssemblerSubscription( + user, + accountViewModel.account, + accountViewModel.dataSources().userFinder, +) + +@Composable +fun UserFinderFilterAssemblerSubscription( + user: User, + forAccount: Account, + dataSource: UserFinderFilterAssembler, +) { + // different screens get different states + // even if they are tracking the same tag. + val state = + remember(user) { + UserFinderQueryState(user, forAccount) + } + + KeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt new file mode 100644 index 0000000000..6b354e41da --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -0,0 +1,703 @@ +/** + * 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.service.relayClient.reqCommand.user + +import android.annotation.SuppressLint +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.UserState +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.sample +import java.math.BigDecimal + +@Composable +fun observeUser( + user: User, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + return user + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() +} + +@OptIn(ExperimentalCoroutinesApi::class) +@Composable +fun observeUserName( + user: User, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user, accountViewModel) + + val flow = + remember(user) { + user + .flow() + .metadata.stateFlow + .mapLatest { it.user.toBestDisplayName() } + .distinctUntilChanged() + } + + // Subscribe in the LocalCache for changes that arrive in the device + return flow.collectAsStateWithLifecycle(user.toBestDisplayName()) +} + +@OptIn(ExperimentalCoroutinesApi::class) +@Composable +fun observeUserNip05( + user: User, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user, accountViewModel) + + val flow = + remember(user) { + user + .flow() + .metadata.stateFlow + .mapLatest { it.user.info?.nip05 } + .distinctUntilChanged() + } + + // Subscribe in the LocalCache for changes that arrive in the device + return flow.collectAsStateWithLifecycle(user.info?.nip05) +} + +@OptIn(ExperimentalCoroutinesApi::class) +@Composable +fun observeUserAboutMe( + user: User, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(user) { + user + .flow() + .metadata.stateFlow + .mapLatest { it.user.info?.about ?: "" } + .distinctUntilChanged() + } + + return flow.collectAsStateWithLifecycle(user.info?.about ?: "") +} + +@OptIn(ExperimentalCoroutinesApi::class) +@Composable +fun observeUserInfo( + user: User, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(user) { + user + .flow() + .metadata.stateFlow + .mapLatest { it.user.info } + .distinctUntilChanged() + } + + return flow.collectAsStateWithLifecycle(user.info) +} + +@OptIn(ExperimentalCoroutinesApi::class) +@Composable +fun observeUserBanner( + user: User, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(user) { + user + .flow() + .metadata.stateFlow + .mapLatest { it.user.info?.banner } + .distinctUntilChanged() + } + + return flow.collectAsStateWithLifecycle(user.info?.banner) +} + +@OptIn(ExperimentalCoroutinesApi::class) +@Composable +fun observeUserPicture( + user: User, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(user) { + user + .flow() + .metadata.stateFlow + .mapLatest { it.user.info?.picture } + .distinctUntilChanged() + } + + return flow.collectAsStateWithLifecycle(user.info?.picture) +} + +@OptIn(ExperimentalCoroutinesApi::class) +@Composable +fun observeUserShortName( + user: User, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(user) { + user + .flow() + .metadata.stateFlow + .mapLatest { it.user.toBestShortFirstName() } + .distinctUntilChanged() + } + + return flow.collectAsStateWithLifecycle(user.toBestShortFirstName()) +} + +@Composable +fun observeUserFollows( + user: User, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + return user + .flow() + .follows.stateFlow + .collectAsStateWithLifecycle() +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@Composable +fun observeUserFollowCount( + user: User, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(user) { + user + .flow() + .followers.stateFlow + .sample(200) + .mapLatest { userState -> + userState.user.transientFollowCount() ?: 0 + }.distinctUntilChanged() + .flowOn(Dispatchers.Default) + } + + return flow.collectAsStateWithLifecycle(0) +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@Composable +fun observeUserTagFollowCount( + user: User, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(user) { + accountViewModel + .hashtagFollows(user) + .flow() + .metadata.stateFlow + .sample(1000) + .mapLatest { noteState -> + (noteState.note.event as? HashtagListEvent)?.let { accountViewModel.account.hashtagListDecryptionCache.hashtags(it) }?.size ?: 0 + }.onStart { + emit((accountViewModel.hashtagFollows(user).event as? HashtagListEvent)?.let { accountViewModel.account.hashtagListDecryptionCache.hashtags(it) }?.size ?: 0) + }.distinctUntilChanged() + .flowOn(Dispatchers.Default) + } + + return flow.collectAsStateWithLifecycle(0) +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@Composable +fun observeUserTagFollows( + user: User, + accountViewModel: AccountViewModel, +): State> { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(user) { + accountViewModel + .hashtagFollows(user) + .flow() + .metadata.stateFlow + .sample(200) + .mapLatest { noteState -> + (noteState.note.event as? HashtagListEvent)?.let { accountViewModel.account.hashtagListDecryptionCache.hashtags(it) }?.sorted() ?: emptyList() + }.onStart { + emit((accountViewModel.hashtagFollows(user).event as? HashtagListEvent)?.let { accountViewModel.account.hashtagListDecryptionCache.hashtags(it) }?.sorted() ?: emptyList()) + }.distinctUntilChanged() + .flowOn(Dispatchers.Default) + } + + return flow.collectAsStateWithLifecycle(emptyList()) +} + +@Composable +fun observeUserBookmarks( + user: User, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(user) { + accountViewModel + .bookmarks(user) + .flow() + .metadata.stateFlow + } + + // Subscribe in the LocalCache for changes that arrive in the device + return flow.collectAsStateWithLifecycle() +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@Composable +fun observeUserBookmarkCount( + user: User, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(user) { + accountViewModel + .bookmarks(user) + .flow() + .metadata.stateFlow + .sample(200) + .mapLatest { noteState -> + (noteState.note.event as? BookmarkListEvent)?.countBookmarks() ?: 0 + }.distinctUntilChanged() + .flowOn(Dispatchers.Default) + } + + return flow.collectAsStateWithLifecycle(0) +} + +@Composable +fun observeUserFollowers( + user: User, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + return user + .flow() + .followers.stateFlow + .collectAsStateWithLifecycle() +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@Composable +fun observeUserFollowerCount( + user: User, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(user) { + user + .flow() + .followers.stateFlow + .sample(200) + .mapLatest { userState -> + userState.user.transientFollowerCount() + }.distinctUntilChanged() + .flowOn(Dispatchers.Default) + } + + return flow.collectAsStateWithLifecycle(0) +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@Composable +fun observeUserIsFollowing( + user1: User, + user2: User, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user1, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(user1) { + user1 + .flow() + .follows.stateFlow + .sample(1000) + .mapLatest { userState -> + userState.user.isFollowing(user2) + }.distinctUntilChanged() + .flowOn(Dispatchers.Default) + } + + return flow.collectAsStateWithLifecycle(user1.isFollowing(user2)) +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@Composable +fun observeUserIsFollowingHashtag( + user: User, + hashtag: String, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(user) { + user + .flow() + .follows.stateFlow + .sample(1000) + .mapLatest { userState -> + userState.user.isFollowingHashtag(hashtag) + }.distinctUntilChanged() + .flowOn(Dispatchers.Default) + } + + return flow.collectAsStateWithLifecycle(user.isFollowingHashtag(hashtag)) +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@Composable +fun observeUserIsFollowingGeohash( + user: User, + geohash: String, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(user) { + user + .flow() + .follows.stateFlow + .sample(1000) + .mapLatest { userState -> + userState.user.isFollowingGeohash(geohash) + }.distinctUntilChanged() + .flowOn(Dispatchers.Default) + } + + return flow.collectAsStateWithLifecycle(user.isFollowingGeohash(geohash)) +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@Composable +fun observeUserIsFollowingChannel( + account: Account, + channel: PublicChatChannel, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(account.userProfile(), accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(account) { + account + .publicChatList + .flowSet + .mapLatest { followingChannels -> + channel.idHex in followingChannels + }.distinctUntilChanged() + .flowOn(Dispatchers.Default) + } + + @SuppressLint("StateFlowValueCalledInComposition") + return flow.collectAsStateWithLifecycle(channel.idHex in account.publicChatList.flowSet.value) +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@Composable +fun observeUserIsFollowingChannel( + account: Account, + channel: EphemeralChatChannel, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(account.userProfile(), accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(account) { + account + .ephemeralChatList + .liveEphemeralChatList + .mapLatest { followingChannels -> + channel.roomId in followingChannels + }.distinctUntilChanged() + .flowOn(Dispatchers.Default) + } + + @SuppressLint("StateFlowValueCalledInComposition") + return flow.collectAsStateWithLifecycle(channel.roomId in account.ephemeralChatList.liveEphemeralChatList.value) +} + +@Composable +fun observeUserZaps( + user: User, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + return user + .flow() + .zaps.stateFlow + .collectAsStateWithLifecycle() +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@Composable +fun observeUserZapAmount( + user: User, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(user) { + user + .flow() + .zaps.stateFlow + .sample(1000) + .mapLatest { userState -> + userState.user.zappedAmount() + }.distinctUntilChanged() + .flowOn(Dispatchers.Default) + } + + return flow.collectAsStateWithLifecycle(BigDecimal.ZERO) +} + +@Composable +fun observeUserReports( + user: User, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + return user + .flow() + .reports.stateFlow + .collectAsStateWithLifecycle() +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@Composable +fun observeUserReportCount( + user: User, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(user) { + user + .flow() + .reports + .stateFlow + .sample(1000) + .mapLatest { userState -> + userState.user.reports.values + .sumOf { it.size } + }.distinctUntilChanged() + } + + return flow.collectAsStateWithLifecycle(0) +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@Composable +fun observeUserStatuses( + user: User, + accountViewModel: AccountViewModel, +): State> { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(user) { + user + .flow() + .statuses + .stateFlow + .sample(1000) + .mapLatest { userState -> + LocalCache.findStatusesForUser(userState.user) + }.distinctUntilChanged() + .flowOn(Dispatchers.Default) + } + + return flow.collectAsStateWithLifecycle(persistentListOf()) +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@Composable +fun observeUserRelayIntoList( + user: User, + relayUrl: NormalizedRelayUrl, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(user) { + user + .flow() + .relayInfo + .stateFlow + .sample(1000) + .mapLatest { userState -> + userState.user.latestContactList + ?.relays() + ?.none { it.key == relayUrl } == true + }.distinctUntilChanged() + .flowOn(Dispatchers.Default) + } + + return flow.collectAsStateWithLifecycle(false) +} + +data class RelayUsage( + val relays: List = emptyList(), + val userRelayList: List = emptyList(), +) + +@OptIn(FlowPreview::class) +@Composable +fun observeUserRelaysUsing( + user: User, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(user) { + combine(user.flow().relays.stateFlow, user.flow().relayInfo.stateFlow) { relays, relayInfo -> + val userRelaysBeingUsed = relays.user.relaysBeingUsed.map { it.key } + val currentUserRelays = + relayInfo.user.latestContactList + ?.relays() + ?.map { it.key } ?: emptyList() + + RelayUsage(userRelaysBeingUsed, currentUserRelays) + }.sample(1000) + .distinctUntilChanged() + .flowOn(Dispatchers.Default) + } + + return flow.collectAsStateWithLifecycle(RelayUsage()) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/FilterUserMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/FilterUserMetadataForKey.kt new file mode 100644 index 0000000000..d147ef3fff --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/FilterUserMetadataForKey.kt @@ -0,0 +1,79 @@ +/** + * 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.service.relayClient.reqCommand.user.loaders + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.utils.mapOfSet + +val MetadataAndRelayListKinds = + listOf( + MetadataEvent.KIND, + AdvertisedRelayListEvent.KIND, + ) + +fun filterFindUserMetadataForKey( + author: HexKey, + defaultRelays: Set, +): List = + LocalCache.checkGetOrCreateUser(author)?.let { + filterFindUserMetadataForKey(setOf(it), defaultRelays) + } ?: emptyList() + +fun filterFindUserMetadataForKey( + authors: Set, + defaultRelays: Set, +): List { + val perRelayKeys = + mapOfSet { + authors.forEach { key -> + val relays = + key.authorRelayList()?.writeRelaysNorm()?.ifEmpty { null } + ?: LocalCache.relayHints.hintsForKey(key.pubkeyHex).ifEmpty { null } + ?: (key.relaysBeingUsed.keys + defaultRelays).toList() + + relays.forEach { + add(it, key.pubkeyHex) + } + } + } + + return perRelayKeys.mapNotNull { + if (it.value.isNotEmpty()) { + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = MetadataAndRelayListKinds, + authors = it.value.sorted(), + ), + ) + } else { + null + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserLoaderSubAssembler.kt new file mode 100644 index 0000000000..83c8181260 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserLoaderSubAssembler.kt @@ -0,0 +1,67 @@ +/** + * 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.service.relayClient.reqCommand.user.loaders + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class UserLoaderSubAssembler( + client: NostrClient, + allKeys: () -> Set, +) : SingleSubNoEoseCacheEoseManager(client, allKeys, invalidateAfterEose = true) { + override fun updateFilter(keys: List): List? { + val firstTimers = mutableSetOf() + + keys.forEach { + if (it.user.latestMetadata == null) { + firstTimers.add(it.user) + } else { + null + } + } + + val defaultRelays = mutableSetOf() + + keys.mapTo(mutableSetOf()) { it.account }.forEach { + defaultRelays.addAll(it.followPlusAllMine.flow.value) + + it.kind3FollowList.flow.value.authors.forEach { + val user = LocalCache.getOrCreateUser(it) + if (user.latestMetadata == null) { + firstTimers.add(user) + } else { + null + } + } + } + + if (firstTimers.isEmpty()) return null + + return filterFindUserMetadataForKey(firstTimers, defaultRelays) + } + + override fun distinct(key: UserFinderQueryState) = key.user +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt new file mode 100644 index 0000000000..d21cce173b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt @@ -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.service.relayClient.reqCommand.user.watchers + +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip56Reports.ReportEvent + +val ReportKindList = listOf(ReportEvent.KIND) + +fun filterReportsToKeysFromTrusted( + targets: Set, + trustedAccounts: Map>, + since: SincePerRelayMap?, +): List { + if (targets.isEmpty() || trustedAccounts.isEmpty()) return emptyList() + val sortedTargets = mapOf("p" to targets.sorted()) + return trustedAccounts.mapNotNull { + if (it.value.isNotEmpty()) { + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = ReportKindList, + authors = it.value.sorted(), + tags = sortedTargets, + since = since?.get(it.key)?.time, + ), + ) + } else { + null + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt new file mode 100644 index 0000000000..ebcd0f627a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt @@ -0,0 +1,70 @@ +/** + * 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.service.relayClient.reqCommand.user.watchers + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.experimental.relationshipStatus.RelationshipStatusEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent + +val UserMetadataForKeyKinds = + listOf( + MetadataEvent.KIND, + StatusEvent.KIND, + RelationshipStatusEvent.KIND, + AdvertisedRelayListEvent.KIND, + ChatMessageRelayListEvent.KIND, + ) + +fun filterUserMetadataForKey( + authors: Set, + since: SincePerRelayMap?, +): List { + val relays = + authors + .map { + val authorHomeRelayEventAddress = AdvertisedRelayListEvent.createAddressTag(it) + val authorHomeRelayEvent = (LocalCache.getAddressableNoteIfExists(authorHomeRelayEventAddress)?.event as? AdvertisedRelayListEvent) + + authorHomeRelayEvent?.writeRelaysNorm()?.ifEmpty { null } + ?: LocalCache.relayHints.hintsForKey(it).ifEmpty { null } + ?: listOfNotNull(LocalCache.getUserIfExists(it)?.latestMetadataRelay) + }.flatten() + .toSet() + + return relays.map { + RelayBasedFilter( + relay = it, + filter = + Filter( + kinds = UserMetadataForKeyKinds, + authors = authors.toList(), + since = since?.get(it)?.time, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt new file mode 100644 index 0000000000..d90135544f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt @@ -0,0 +1,126 @@ +/** + * 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.service.relayClient.reqCommand.user.watchers + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState +import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.ammolite.relays.filters.MutableTime +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.mapOfSet + +class UserReportsSubAssembler( + client: NostrClient, + allKeys: () -> Set, +) : SingleSubEoseManager(client, allKeys) { + var lastUsersOnFilter: Set = emptySet() + + /** + * This assembler saves the EOSE per user key. That EOSE includes their metadata, etc + * and reports, but only from trusted accounts (follows of all logged in users). + */ + var latestEOSEs: EOSEAccountFast = EOSEAccountFast(2000) + + override fun newEose( + relay: NormalizedRelayUrl, + time: Long, + ) { + lastUsersOnFilter.forEach { + latestEOSEs.newEose(it, relay, time) + } + super.newEose(relay, time) + } + + override fun updateFilter( + keys: List, + since: SincePerRelayMap?, + ): List? { + if (keys.isEmpty()) return null + + lastUsersOnFilter = keys.mapTo(mutableSetOf()) { it.user } + + if (lastUsersOnFilter.isEmpty()) return null + + val accounts = keys.mapTo(mutableSetOf()) { it.account } + + val trustedAccounts = + mapOfSet { + accounts.map { it.followsPerRelay.value }.forEach { + add(it) + } + } + + return groupByRelayPresence(lastUsersOnFilter, latestEOSEs, trustedAccounts.keys) + .map { group -> + val groupIds = group.map { it.pubkeyHex }.toSet() + if (groupIds.isNotEmpty()) { + val minEOSEs = findMinimumEOSEsForUsers(group, latestEOSEs) + filterReportsToKeysFromTrusted(groupIds, trustedAccounts, minEOSEs) + } else { + emptyList() + } + }.flatten() + } + + fun groupByRelayPresence( + users: Iterable, + eoseCache: EOSEAccountFast, + inRelays: Set, + ): Collection> = + users + .groupBy { + eoseCache + .since(it) + ?.keys + ?.intersect(inRelays) + ?.hashCode() + }.values + .map { + // important to keep in order otherwise the Relay thinks the filter has changed and we REQ again + it.sortedBy { it.pubkeyHex } + } + + fun findMinimumEOSEsForUsers( + users: List, + eoseCache: EOSEAccountFast, + ): SincePerRelayMap { + val minLatestEOSEs = mutableMapOf() + + users.forEach { + eoseCache.since(it)?.forEach { + val minEose = minLatestEOSEs[it.key] + if (minEose == null) { + minLatestEOSEs.put(it.key, it.value.copy()) + } else { + minEose.updateIfOlder(it.value.time) + } + } + } + + return minLatestEOSEs + } + + override fun distinct(key: UserFinderQueryState) = key.user +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt new file mode 100644 index 0000000000..1c9cfb4779 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt @@ -0,0 +1,113 @@ +/** + * 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.service.relayClient.reqCommand.user.watchers + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState +import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.ammolite.relays.filters.MutableTime +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class UserWatcherSubAssembler( + client: NostrClient, + allKeys: () -> Set, +) : SingleSubEoseManager(client, allKeys) { + var lastUsersOnFilter: Set = emptySet() + + /** + * This assembler saves the EOSE per user key. That EOSE includes their metadata, etc + * and reports, but only from trusted accounts (follows of all logged in users). + */ + var latestEOSEs: EOSEAccountFast = EOSEAccountFast(2000) + + override fun newEose( + relay: NormalizedRelayUrl, + time: Long, + ) { + lastUsersOnFilter.forEach { + latestEOSEs.newEose(it, relay, time) + } + super.newEose(relay, time) + } + + override fun updateFilter( + keys: List, + since: SincePerRelayMap?, + ): List? { + if (keys.isEmpty()) return null + + lastUsersOnFilter = + keys.mapNotNullTo(mutableSetOf()) { + if (it.user.latestMetadata != null) it.user else null + } + + if (lastUsersOnFilter.isEmpty()) return null + + return groupByRelayPresence(lastUsersOnFilter, latestEOSEs) + .map { group -> + val groupIds = group.map { it.pubkeyHex }.toSet() + if (groupIds.isNotEmpty()) { + val minEOSEs = findMinimumEOSEsForUsers(group, latestEOSEs) + filterUserMetadataForKey(groupIds, minEOSEs) + } else { + emptyList() + } + }.flatten() + } + + fun groupByRelayPresence( + users: Iterable, + eoseCache: EOSEAccountFast, + ): Collection> = + users + .groupBy { eoseCache.since(it)?.keys?.hashCode() } + .values + .map { + // important to keep in order otherwise the Relay thinks the filter has changed and we REQ again + it.sortedBy { it.pubkeyHex } + } + + fun findMinimumEOSEsForUsers( + users: List, + eoseCache: EOSEAccountFast, + ): SincePerRelayMap { + val minLatestEOSEs = mutableMapOf() + + users.forEach { + eoseCache.since(it)?.forEach { + val minEose = minLatestEOSEs[it.key] + if (minEose == null) { + minLatestEOSEs.put(it.key, it.value.copy()) + } else { + minEose.updateIfOlder(it.value.time) + } + } + } + + return minLatestEOSEs + } + + override fun distinct(key: UserFinderQueryState) = key.user +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/SearchFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/SearchFilterAssembler.kt new file mode 100644 index 0000000000..754332de8c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/SearchFilterAssembler.kt @@ -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.service.relayClient.searchCommand + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.MutableComposeSubscriptionManager +import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.MutableQueryState +import com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies.SearchWatcherSubAssembler +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +@Stable +class SearchQueryState( + val searchQuery: MutableStateFlow, + val account: Account, +) : MutableQueryState { + override fun flow(): Flow = searchQuery +} + +class SearchFilterAssembler( + client: NostrClient, + scope: CoroutineScope, + val cache: LocalCache, +) : MutableComposeSubscriptionManager(scope) { + val group = + listOf( + SearchWatcherSubAssembler(cache, client, ::allKeys), + ) + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun invalidateKeys() = invalidateFilters() + + override fun destroy() = group.forEach { it.destroy() } + + override fun printStats() = group.forEach { it.printStats() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/TextSearchDataSourceSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/TextSearchDataSourceSubscription.kt new file mode 100644 index 0000000000..534621242c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/TextSearchDataSourceSubscription.kt @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.searchCommand + +import androidx.compose.runtime.Composable +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchBarViewModel + +@Composable +fun TextSearchDataSourceSubscription( + searchBarViewModel: SearchBarViewModel, + accountViewModel: AccountViewModel, +) = TextSearchDataSourceSubscription(searchBarViewModel, accountViewModel.dataSources().search) + +@Composable +fun TextSearchDataSourceSubscription( + searchBarViewModel: SearchBarViewModel, + dataSource: SearchFilterAssembler, +) { + KeyDataSourceSubscription(searchBarViewModel.searchDataSourceState, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/UserSearchDataSourceSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/UserSearchDataSourceSubscription.kt new file mode 100644 index 0000000000..c6a337bee3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/UserSearchDataSourceSubscription.kt @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.searchCommand + +import androidx.compose.runtime.Composable +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun UserSearchDataSourceSubscription( + userSuggestions: UserSuggestionState, + accountViewModel: AccountViewModel, +) = UserSearchDataSourceSubscription(userSuggestions, accountViewModel.dataSources().search) + +@Composable +fun UserSearchDataSourceSubscription( + userSuggestions: UserSuggestionState, + dataSource: SearchFilterAssembler, +) { + KeyDataSourceSubscription(userSuggestions.searchDataSourceState, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAddress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAddress.kt new file mode 100644 index 0000000000..7b4ee76089 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAddress.kt @@ -0,0 +1,47 @@ +/** + * 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.service.relayClient.searchCommand.subassemblies + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingAddressables +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindAddress +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.utils.mapOfSet + +fun filterByAddress( + address: NAddress, + default: Set, +): List { + val note = LocalCache.getOrCreateAddressableNote(address.address()) + + val list = + mapOfSet { + if (note.event == null) { + potentialRelaysToFindAddress(note).ifEmpty { default }.forEach { relayUrl -> + add(relayUrl, note.address) + } + } + } + + return filterMissingAddressables(list) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt new file mode 100644 index 0000000000..c3b2efd28a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies + +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders.filterFindUserMetadataForKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +fun filterByAuthor( + pubKey: HexKey, + defaultRelays: Set, +) = filterFindUserMetadataForKey(pubKey, defaultRelays) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByEvent.kt new file mode 100644 index 0000000000..5401aeaa13 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByEvent.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies + +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingEvents +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.mapOfSet + +fun filterByEvent( + eventId: HexKey, + default: Set, +): List { + val note = LocalCache.checkGetOrCreateNote(eventId) ?: return emptyList() + + val list = + mapOfSet { + if (note !is AddressableNote && note.event == null) { + potentialRelaysToFindEvent(note).ifEmpty { default }.forEach { relayUrl -> + add(relayUrl, note.idHex) + } + } + + // loads threading that is event-based + note.replyTo?.forEach { parentNote -> + if (parentNote !is AddressableNote && note.event == null) { + potentialRelaysToFindEvent(note).ifEmpty { default }.forEach { relayUrl -> + add(relayUrl, note.idHex) + } + } + } + } + + return filterMissingEvents(list) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPeopleByName.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPeopleByName.kt new file mode 100644 index 0000000000..8c3a5c0956 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPeopleByName.kt @@ -0,0 +1,42 @@ +/** + * 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.service.relayClient.searchCommand.subassemblies + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +fun searchPeopleByName( + searchString: HexKey, + relay: NormalizedRelayUrl, +) = listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(MetadataEvent.KIND), + search = searchString, + limit = 1000, + ), + ), +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt new file mode 100644 index 0000000000..dd1cf3e629 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt @@ -0,0 +1,117 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies + +import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent +import com.vitorpamplona.quartz.experimental.nns.NNSEvent +import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +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.nip30CustomEmoji.pack.EmojiPackEvent +import com.vitorpamplona.quartz.nip51Lists.PinListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent + +val SearchPostsByTextKinds1 = + listOf( + TextNoteEvent.KIND, + LongTextNoteEvent.KIND, + BadgeDefinitionEvent.KIND, + PeopleListEvent.KIND, + BookmarkListEvent.KIND, + AudioHeaderEvent.KIND, + AudioTrackEvent.KIND, + PinListEvent.KIND, + PollNoteEvent.KIND, + ChannelCreateEvent.KIND, + ) + +val SearchPostsByTextKinds2 = + listOf( + ChannelMetadataEvent.KIND, + ClassifiedsEvent.KIND, + CommunityDefinitionEvent.KIND, + EmojiPackEvent.KIND, + HighlightEvent.KIND, + LiveActivitiesEvent.KIND, + PublicMessageEvent.KIND, + NNSEvent.KIND, + WikiNoteEvent.KIND, + CommentEvent.KIND, + ) + +val SearchPostsByTextKinds3 = + listOf( + InteractiveStoryPrologueEvent.KIND, + InteractiveStorySceneEvent.KIND, + FollowListEvent.KIND, + ) + +fun searchPostsByText( + searchString: HexKey, + relay: NormalizedRelayUrl, +) = listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = SearchPostsByTextKinds1, + search = searchString, + limit = 100, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = SearchPostsByTextKinds2, + search = searchString, + limit = 100, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = SearchPostsByTextKinds3, + search = searchString, + limit = 100, + ), + ), +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchWatcherSubAssembler.kt new file mode 100644 index 0000000000..edd074b732 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchWatcherSubAssembler.kt @@ -0,0 +1,99 @@ +/** + * 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.service.relayClient.searchCommand.subassemblies + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager +import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay +import com.vitorpamplona.quartz.nip19Bech32.entities.NSec +import com.vitorpamplona.quartz.utils.Hex + +/** + * Creates a new sub for each Search Screen. The StateFlow of the text field + * that has the search ter is the id of the sub. + */ +class SearchWatcherSubAssembler( + val cache: LocalCache, + client: NostrClient, + allKeys: () -> Set, +) : PerUniqueIdEoseManager(client, allKeys) { + override fun updateFilter( + key: SearchQueryState, + since: SincePerRelayMap?, + ): List? { + val mySearchString = key.searchQuery.value + + if (mySearchString.isBlank()) return null + + val defaultRelays = key.account.followPlusAllMine.flow.value + + val directFilters = + runCatching { + if (Hex.isHex(mySearchString)) { + val hexKey = Hex.decode(mySearchString).toHexKey() + filterByAuthor(hexKey, defaultRelays) + filterByEvent(hexKey, defaultRelays) + } else { + val parsed = Nip19Parser.uriToRoute(mySearchString)?.entity + if (parsed != null) { + cache.consume(parsed) + + when (parsed) { + is NSec -> filterByAuthor(parsed.toPubKeyHex(), defaultRelays) + is NPub -> filterByAuthor(parsed.hex, defaultRelays) + is NProfile -> filterByAuthor(parsed.hex, defaultRelays) + is NNote -> filterByEvent(parsed.hex, defaultRelays) + is NEvent -> filterByEvent(parsed.hex, defaultRelays) + is NEmbed -> emptyList() + is NRelay -> emptyList() + is NAddress -> filterByAddress(parsed, defaultRelays) + else -> emptyList() + } + } else { + emptyList() + } + } + }.getOrDefault(emptyList()) + + val searchFilters = + key.account.searchRelayList.flow.value.flatMap { + searchPeopleByName(mySearchString, it) + } + + key.account.searchRelayList.flow.value.flatMap { + searchPostsByText(mySearchString, it) + } + + return directFilters + searchFilters + } + + override fun id(key: SearchQueryState) = key.searchQuery.hashCode() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/FrameStat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/FrameStat.kt new file mode 100644 index 0000000000..6be8af457c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/FrameStat.kt @@ -0,0 +1,77 @@ +/** + * 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.service.relayClient.speedLogger + +import android.util.Log +import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger.Companion.TAG +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.LargeCache +import java.util.concurrent.atomic.AtomicInteger +import kotlin.concurrent.timer + +class FrameStat { + var eventCount = AtomicInteger(0) + var kinds = LargeCache() + + fun increment( + kind: Int, + subId: String, + relayUrl: NormalizedRelayUrl, + memory: Long, + ) { + eventCount.incrementAndGet() + + val kindGroup = kinds.get(kind) + if (kindGroup != null) { + kindGroup.increment(memory, subId, relayUrl) + } else { + val group = KindGroup() + group.increment(memory, subId, relayUrl) + kinds.put(kind, group) + } + } + + fun hasAnything() = eventCount.get() > 0 + + fun reset() { + eventCount.set(0) + kinds.forEach { key, value -> value.reset() } + } + + fun log() { + Log.d(TAG, "Events Per Second: ${eventCount.get()}") + kinds.forEach { key, value -> + if (value.count.get() > 0) { + Log.d(TAG, "-- Kind $key $value") + } + } + } + + init { + // Use a timer to reset the counter every second. + timer(name = "EventsPerSecondCounter", period = 1000, daemon = true) { + if (hasAnything()) { + log() + reset() + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/KindGroup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/KindGroup.kt new file mode 100644 index 0000000000..4fa495f174 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/KindGroup.kt @@ -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.service.relayClient.speedLogger + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.utils.LargeCache +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong +import kotlin.concurrent.atomics.ExperimentalAtomicApi + +@OptIn(ExperimentalAtomicApi::class) +class KindGroup( + var count: AtomicInteger = AtomicInteger(0), + var memory: AtomicLong = AtomicLong(0L), + val subs: LargeCache = LargeCache(), + val relays: LargeCache = LargeCache(), +) { + companion object { + const val MB: Long = 1024 + } + + fun increment( + mem: Long, + subId: String, + relayUrl: NormalizedRelayUrl, + ) { + count.incrementAndGet() + memory.addAndGet(mem) + + val subStats = subs.get(subId) + if (subStats != null) { + subStats.incrementAndGet() + } else { + subs.put(subId, AtomicInteger(1)) + } + + val relayStats = relays.get(relayUrl) + if (relayStats != null) { + relayStats.incrementAndGet() + } else { + relays.put(relayUrl, AtomicInteger(1)) + } + } + + fun reset() { + count.set(0) + memory.set(0L) + subs.forEach { key, value -> value.set(0) } + relays.forEach { key, value -> value.set(0) } + } + + fun printSubs() = subs.joinToString(", ") { key, value -> if (value.get() > 0) "$key ($value)" else "" } + + fun printRelays() = relays.joinToString(", ") { key, value -> if (value.get() > 0) "${key.displayUrl()} ($value)" else "" } + + override fun toString() = "(${count.get()} - ${memory.get().div(MB)}kb); ${printSubs()}; ${printRelays()}" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt new file mode 100644 index 0000000000..8cbc9a1cb3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt @@ -0,0 +1,66 @@ +/** + * 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.service.relayClient.speedLogger + +import android.util.Log +import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger.Companion.TAG +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient + +/** + * Listens to NostrClient's onNotify messages from the relay + */ +class RelaySpeedLogger( + val client: NostrClient, +) { + companion object { + val TAG = RelaySpeedLogger::class.java.simpleName + } + + var current = FrameStat() + + private val clientListener = + object : IRelayClientListener { + /** A new message was received */ + override fun onEvent( + relay: IRelayClient, + subId: String, + event: Event, + arrivalTime: Long, + afterEOSE: Boolean, + ) { + current.increment(event.kind, subId, relay.url, event.countMemory()) + } + } + + init { + Log.d(TAG, "Init, Subscribe") + client.subscribe(clientListener) + } + + fun destroy() { + // makes sure to run + Log.d(TAG, "Destroy, Unsubscribe") + client.unsubscribe(clientListener) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt index ea18cabf95..4e529baac1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt @@ -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,64 +20,137 @@ */ package com.vitorpamplona.amethyst.service.relays +import androidx.collection.LruCache import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.ammolite.relays.filters.MutableTime +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +typealias SincePerRelayMap = Map + +class EOSERelayList { + var relayList: SincePerRelayMap = emptyMap() -class EOSERelayList( - var relayList: Map = emptyMap(), -) { fun addOrUpdate( - relayUrl: String, + relayUrl: NormalizedRelayUrl, time: Long, ) { val eose = relayList[relayUrl] if (eose == null) { - relayList = relayList + Pair(relayUrl, EOSETime(time)) + relayList = relayList + Pair(relayUrl, MutableTime(time)) } else { - eose.time = time + eose.updateIfNewer(time) } } + + fun clear() { + relayList = emptyMap() + } + + fun since() = relayList + + fun newEose( + relay: NormalizedRelayUrl, + time: Long, + ) = addOrUpdate(relay, time) } -class EOSEFollowList( - var followList: Map = emptyMap(), +open class EOSEByKey( + cacheSize: Int = 200, ) { + var followList: LruCache = LruCache(cacheSize) + fun addOrUpdate( - listCode: String, - relayUrl: String, + listCode: U, + relayUrl: NormalizedRelayUrl, time: Long, ) { val relayList = followList[listCode] if (relayList == null) { val newList = EOSERelayList() newList.addOrUpdate(relayUrl, time) - followList = followList + mapOf(listCode to newList) + followList.put(listCode, newList) } else { relayList.addOrUpdate(relayUrl, time) } } + + fun since(listCode: U) = followList[listCode]?.relayList + + fun newEose( + listCode: U, + relayUrl: NormalizedRelayUrl, + time: Long, + ) = addOrUpdate(listCode, relayUrl, time) } -class EOSEAccount( - var users: Map = emptyMap(), +open class EOSEAccountKey( + cacheSize: Int = 20, ) { + var users: LruCache> = LruCache>(cacheSize) + fun addOrUpdate( user: User, - listCode: String, - relayUrl: String, + listCode: U, + relayUrl: NormalizedRelayUrl, time: Long, ) { val followList = users[user] if (followList == null) { - val newList = EOSEFollowList() + val newList = EOSEByKey() + users.put(user, newList) newList.addOrUpdate(listCode, relayUrl, time) - users = users + mapOf(user to newList) } else { followList.addOrUpdate(listCode, relayUrl, time) } } fun removeDataFor(user: User) { - users = users.minus(user) + users.remove(user) } + + fun since( + key: User, + listCode: U, + ) = users[key]?.followList?.get(listCode)?.relayList + + fun newEose( + user: User, + listCode: U, + relayUrl: NormalizedRelayUrl, + time: Long, + ) = addOrUpdate(user, listCode, relayUrl, time) +} + +class EOSEAccountFast( + cacheSize: Int = 20, +) { + private val users: LruCache = LruCache(cacheSize) + + fun addOrUpdate( + user: T, + relayUrl: NormalizedRelayUrl, + time: Long, + ) { + val relayList = users[user] + if (relayList == null) { + val newList = EOSERelayList() + users.put(user, newList) + + newList.addOrUpdate(relayUrl, time) + } else { + relayList.addOrUpdate(relayUrl, time) + } + } + + fun removeDataFor(user: T) { + users.remove(user) + } + + fun since(key: T) = users[key]?.relayList + + fun newEose( + user: T, + relayUrl: NormalizedRelayUrl, + time: Long, + ) = addOrUpdate(user, relayUrl, time) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechEngine.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechEngine.kt index 5c920ed8b7..09bd72fa8f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechEngine.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechEngine.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechHelper.kt index 351267a705..cc0f365dd0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechHelper.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/EncryptFiles.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/EncryptFiles.kt index 2f8db2a447..1babc6c493 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/EncryptFiles.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/EncryptFiles.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt index b61da8bcfc..afb2e8d2b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/ImageDownloader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/ImageDownloader.kt index a515e59398..0407f799a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/ImageDownloader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/ImageDownloader.kt @@ -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 @@ -42,7 +42,7 @@ class ImageDownloader { var imageData: Blob? = null var tentatives = 0 - // Servers are usually not ready.. so tries to download it for 15 times/seconds. + // Servers are usually not ready, so tries to download it for 15 times/seconds. while (imageData == null && tentatives < 15) { imageData = try { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt index e4bacdca97..4288337a21 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt @@ -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 @@ -108,7 +108,7 @@ class MediaCompressor { // videoName = "compressed_video" // => required name // ), // OR AND NOT BOTH - appSpecificStorageConfiguration = AppSpecificStorageConfiguration(), + storageConfiguration = AppSpecificStorageConfiguration(), configureWith = Configuration( quality = videoQuality, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaMetadataRetrieverExt.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaMetadataRetrieverExt.kt index 3d386b7a80..3d9f710271 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaMetadataRetrieverExt.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaMetadataRetrieverExt.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaUploadResult.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaUploadResult.kt index 347d374275..ad65e17569 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaUploadResult.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaUploadResult.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt index 27ee4b75e9..424a3cd82f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt @@ -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,8 +27,8 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.quartz.nip17Dm.files.encryption.NostrCipher -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch @@ -47,7 +47,6 @@ class MultiOrchestrator( fun first() = list.first() suspend fun upload( - scope: CoroutineScope, alt: String?, contentWarningReason: String?, mediaQuality: CompressorQuality, @@ -55,29 +54,30 @@ class MultiOrchestrator( account: Account, context: Context, ): Result { - val jobs = - list.map { item -> - scope.launch(Dispatchers.IO) { - item.orchestrator.upload( - item.media.uri, - item.media.mimeType, - alt, - contentWarningReason, - mediaQuality, - server, - account, - context, - ) + coroutineScope { + val jobs = + list.map { item -> + launch(Dispatchers.IO) { + item.orchestrator.upload( + item.media.uri, + item.media.mimeType, + alt, + contentWarningReason, + mediaQuality, + server, + account, + context, + ) + } } - } - jobs.joinAll() + jobs.joinAll() + } return computeFinalResults() } suspend fun uploadEncrypted( - scope: CoroutineScope, alt: String?, contentWarningReason: String?, mediaQuality: CompressorQuality, @@ -86,24 +86,25 @@ class MultiOrchestrator( account: Account, context: Context, ): Result { - val jobs = - list.map { item -> - scope.launch(Dispatchers.IO) { - item.orchestrator.uploadEncrypted( - item.media.uri, - item.media.mimeType, - alt, - contentWarningReason, - mediaQuality, - cipher, - server, - account, - context, - ) + coroutineScope { + val jobs = + list.map { item -> + launch(Dispatchers.IO) { + item.orchestrator.uploadEncrypted( + item.media.uri, + item.media.mimeType, + alt, + contentWarningReason, + mediaQuality, + cipher, + server, + account, + context, + ) + } } - } - - jobs.joinAll() + jobs.joinAll() + } return computeFinalResults() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt index d36dbd8c76..4423bd16c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt @@ -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 @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip17Dm.files.encryption.NostrCipher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map @@ -151,7 +152,7 @@ class UploadOrchestrator { alt = alt, sensitiveContent = contentWarningReason, serverBaseUrl = serverBaseUrl, - okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(it)) }, + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(it)) }, onProgress = { percent: Float -> updateState(0.2 + (0.2 * percent), UploadingState.Uploading) }, @@ -164,8 +165,10 @@ class UploadOrchestrator { localContentType = contentType, originalContentType = contentTypeForResult, originalHash = originalHash, - okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(it)) }, + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(it)) }, ) + } catch (_: SignerExceptions.ReadOnlyException) { + error(R.string.login_with_a_private_key_to_be_able_to_upload) } catch (e: Exception) { if (e is CancellationException) throw e error(R.string.failed_to_upload_media, e.message ?: e.javaClass.simpleName) @@ -195,7 +198,7 @@ class UploadOrchestrator { alt = alt, sensitiveContent = contentWarningReason, serverBaseUrl = serverBaseUrl, - okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(it)) }, + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(it)) }, httpAuth = account::createBlossomUploadAuth, context = context, ) @@ -203,10 +206,12 @@ class UploadOrchestrator { verifyHeader( uploadResult = result, localContentType = contentType, - okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(it)) }, + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(it)) }, originalHash = originalHash, originalContentType = contentTypeForResult, ) + } catch (_: SignerExceptions.ReadOnlyException) { + error(R.string.login_with_a_private_key_to_be_able_to_upload) } catch (e: Exception) { if (e is CancellationException) throw e error(R.string.failed_to_upload_media, e.message ?: e.javaClass.simpleName) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt index b92462802f..7979093cbd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt @@ -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,21 +27,23 @@ import android.provider.OpenableColumns import android.webkit.MimeTypeMap import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.HttpStatusMessages import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult -import com.vitorpamplona.amethyst.service.uploads.nip96.randomChars import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent +import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody +import okhttp3.coroutines.executeAsync import okio.BufferedSink import okio.source import java.io.File @@ -129,7 +131,7 @@ class BlossomUploader { ): MediaUploadResult { checkNotInMainThread() - val fileName = baseFileName ?: randomChars() + val fileName = baseFileName ?: RandomInstance.randomChars(16) val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" @@ -157,29 +159,28 @@ class BlossomUploader { requestBuilder .addHeader("Content-Length", length.toString()) - .addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(apiUrl) .put(requestBody) val request = requestBuilder.build() - client.newCall(request).execute().use { response -> - if (response.isSuccessful) { - response.body.use { body -> - val str = body.string() - val result = parseResults(str) - return result - } - } else { - val errorMessage = response.headers.get("X-Reason") - - val explanation = HttpStatusMessages.resourceIdFor(response.code) - if (errorMessage != null) { - throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), errorMessage)) - } else if (explanation != null) { - throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), stringRes(context, explanation))) + return client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + if (response.isSuccessful) { + response.body.use { body -> + parseResults(body.string()) + } } else { - throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), response.code.toString())) + val errorMessage = response.headers.get("X-Reason") + + val explanation = HttpStatusMessages.resourceIdFor(response.code) + if (errorMessage != null) { + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), errorMessage)) + } else if (explanation != null) { + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), stringRes(context, explanation))) + } else { + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), response.code.toString())) + } } } } @@ -208,20 +209,21 @@ class BlossomUploader { val request = requestBuilder - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(apiUrl.removeSuffix("/") + "/$hash.$extension") .delete() .build() - okHttpClient(apiUrl).newCall(request).execute().use { response -> - if (response.isSuccessful) { - return true - } else { - val explanation = HttpStatusMessages.resourceIdFor(response.code) - if (explanation != null) { - throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, stringRes(context, explanation))) + return okHttpClient(apiUrl).newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + if (response.isSuccessful) { + true } else { - throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, response.code)) + val explanation = HttpStatusMessages.resourceIdFor(response.code) + if (explanation != null) { + throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, stringRes(context, explanation))) + } else { + throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, response.code)) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip95/Nip95CacheFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip95/Nip95CacheFactory.kt index 3709b37b10..31910cb74b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip95/Nip95CacheFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip95/Nip95CacheFactory.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt index 49cdab69b3..b6acdfde82 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt @@ -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,7 +27,6 @@ import android.provider.OpenableColumns import android.webkit.MimeTypeMap import androidx.core.net.toFile import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.HttpStatusMessages import com.vitorpamplona.amethyst.service.checkNotInMainThread @@ -40,20 +39,20 @@ import com.vitorpamplona.quartz.nip96FileStorage.actions.PartialEvent import com.vitorpamplona.quartz.nip96FileStorage.actions.UploadResult import com.vitorpamplona.quartz.nip96FileStorage.info.ServerInfo import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent +import com.vitorpamplona.quartz.utils.RandomInstance +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody +import okhttp3.coroutines.executeAsync import okio.BufferedSink import okio.source import java.io.InputStream -val charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9') - -fun randomChars() = List(16) { charPool.random() }.joinToString("") - class Nip96Uploader { suspend fun upload( uri: Uri, @@ -138,7 +137,7 @@ class Nip96Uploader { ): MediaUploadResult { checkNotInMainThread() - val fileName = randomChars() + val fileName = RandomInstance.randomChars(16) val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" val client = okHttpClient(server.apiUrl) @@ -171,53 +170,54 @@ class Nip96Uploader { httpAuth(server.apiUrl, "POST", null)?.let { requestBuilder.addHeader("Authorization", it.toAuthToken()) } requestBuilder - .addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(server.apiUrl) .post(requestBody) val request = requestBuilder.build() - client.newCall(request).execute().use { response -> - if (response.isSuccessful) { - response.body.use { body -> - val result = UploadResult.parse(body.string()) - if (!result.processingUrl.isNullOrBlank()) { - return waitProcessing(result, server, okHttpClient, onProgress) - } else if (result.status == "success") { - val event = result.nip94Event - if (event != null) { - return convertToMediaResult(event) + return client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + if (response.isSuccessful) { + response.body.use { body -> + val result = UploadResult.parse(body.string()) + if (!result.processingUrl.isNullOrBlank()) { + waitProcessing(result, server, okHttpClient, onProgress) + } else if (result.status == "success") { + val event = result.nip94Event + if (event != null) { + convertToMediaResult(event) + } else { + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), result.message)) + } } else { throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), result.message)) } - } else { - throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), result.message)) } - } - } else { - val msg = response.body.string() + } else { + val msg = response.body.string() - val errorMessage = - try { - val tree = jacksonObjectMapper().readTree(msg) - val status = tree.get("status")?.asText() - val message = tree.get("message")?.asText() - if (status == "error" && message != null) { - message - } else { + val errorMessage = + try { + val tree = jacksonObjectMapper().readTree(msg) + val status = tree.get("status")?.asText() + val message = tree.get("message")?.asText() + if (status == "error" && message != null) { + message + } else { + null + } + } catch (e: Exception) { null } - } catch (e: Exception) { - null - } - val explanation = HttpStatusMessages.resourceIdFor(response.code) - if (errorMessage != null) { - throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), errorMessage)) - } else if (explanation != null) { - throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), stringRes(context, explanation))) - } else { - throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), response.code.toString())) + val explanation = HttpStatusMessages.resourceIdFor(response.code) + if (errorMessage != null) { + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), errorMessage)) + } else if (explanation != null) { + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), stringRes(context, explanation))) + } else { + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), response.code.toString())) + } } } } @@ -278,23 +278,22 @@ class Nip96Uploader { val request = requestBuilder - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(server.apiUrl.removeSuffix("/") + "/$hash.$extension") .delete() .build() - client.newCall(request).execute().use { response -> - if (response.isSuccessful) { - response.body.use { body -> - val result = DeleteResult.parse(body.string()) - return result.status == "success" - } - } else { - val explanation = HttpStatusMessages.resourceIdFor(response.code) - if (explanation != null) { - throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, stringRes(context, explanation))) + return client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + if (response.isSuccessful) { + val result = DeleteResult.parse(response.body.string()) + result.status == "success" } else { - throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, response.code)) + val explanation = HttpStatusMessages.resourceIdFor(response.code) + if (explanation != null) { + throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, stringRes(context, explanation))) + } else { + throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, response.code)) + } } } } @@ -316,14 +315,15 @@ class Nip96Uploader { val request: Request = Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(procUrl) .build() val client = okHttpClient(procUrl) - client.newCall(request).execute().use { - if (it.isSuccessful) { - it.body.use { currentResult = UploadResult.parse(it.string()) } + client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + if (response.isSuccessful) { + currentResult = UploadResult.parse(response.body.string()) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/ServerInfoRetriever.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/ServerInfoRetriever.kt index 2906bbe140..55cf8147f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/ServerInfoRetriever.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/ServerInfoRetriever.kt @@ -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,12 +21,14 @@ package com.vitorpamplona.amethyst.service.uploads.nip96 import android.util.Log -import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.quartz.nip96FileStorage.info.ServerInfo import com.vitorpamplona.quartz.nip96FileStorage.info.ServerInfoParser import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.coroutines.executeAsync class ServerInfoRetriever { val parser = ServerInfoParser() @@ -42,27 +44,25 @@ class ServerInfoRetriever { .url(parser.assembleUrl(baseUrl)) .build() - println("AABBCC $baseUrl Request ${parser.assembleUrl(baseUrl)}") + val client = okHttpClient(baseUrl) - okHttpClient(baseUrl).newCall(request).execute().use { response -> - println("AABBCC $baseUrl Response") - checkNotInMainThread() - response.use { - val body = it.body.string() - try { - if (it.isSuccessful) { - return parser.parse(baseUrl, body) + return try { + client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + if (response.isSuccessful) { + val body = response.body.string() + parser.parse(baseUrl, body) } else { - throw RuntimeException( + throw Exception( "Resulting Message from $baseUrl is an error: ${response.code} ${response.message}", ) } - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e("RelayInfoFail", "Resulting Message from $baseUrl in not parseable: $body", e) - throw e } } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("RelayInfoFail", "Resulting Message from $baseUrl", e) + throw e } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 9ea808ca83..8e0703715c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -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,29 +34,26 @@ import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.debugState +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.AccountScreen import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.prepareSharedViewModel import com.vitorpamplona.amethyst.ui.theme.AmethystTheme -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub -import com.vitorpamplona.quartz.nip19Bech32.entities.Note -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.nip47WalletConnect.Nip47WalletConnect -import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent -import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import kotlinx.coroutines.CancellationException import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers @@ -79,7 +76,7 @@ class MainActivity : AppCompatActivity() { val accountStateViewModel: AccountStateViewModel = viewModel() LaunchedEffect(key1 = Unit) { - accountStateViewModel.tryLoginExistingAccountAsync() + accountStateViewModel.loginWithDefaultAccountIfLoggedOff() } AccountScreen(accountStateViewModel, sharedPreferencesViewModel) @@ -100,9 +97,16 @@ class MainActivity : AppCompatActivity() { override fun onPause() { Log.d("ActivityLifecycle", "MainActivity.onPause $this") - GlobalScope.launch(Dispatchers.IO) { LanguageTranslatorService.clear() } + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { + LanguageTranslatorService.clear() + } - GlobalScope.launch(Dispatchers.IO) { debugState(this@MainActivity) } + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { + debugState(this@MainActivity) + Amethyst.instance.sources.printCounters() + } super.onPause() } @@ -111,6 +115,7 @@ class MainActivity : AppCompatActivity() { super.onStop() // Graph doesn't completely clear. + // @OptIn(DelicateCoroutinesApi::class) // GlobalScope.launch(Dispatchers.Default) { // serviceManager.trimMemory() // } @@ -137,7 +142,10 @@ class MainActivity : AppCompatActivity() { } } -fun uriToRoute(uri: String?): Route? = +fun uriToRoute( + uri: String?, + account: Account, +): Route? = if (uri?.startsWith("notifications", true) == true || uri?.startsWith("nostr:notifications", true) == true) { Route.Notification } else { @@ -145,39 +153,40 @@ fun uriToRoute(uri: String?): Route? = Route.Hashtag(uri.removePrefix("nostr:").removePrefix("hashtag?id=")) } else { val nip19 = Nip19Parser.uriToRoute(uri)?.entity + if (nip19 != null) { + LocalCache.consume(nip19) + } when (nip19) { is NPub -> Route.Profile(nip19.hex) is NProfile -> Route.Profile(nip19.hex) - is Note -> Route.Note(nip19.hex) + is NNote -> Route.Note(nip19.hex) is NEvent -> { - if (nip19.kind == PrivateDmEvent.KIND) { - nip19.author?.let { Route.RoomByAuthor(it) } - } else if ( - nip19.kind == ChannelMessageEvent.KIND || - nip19.kind == ChannelCreateEvent.KIND || - nip19.kind == ChannelMetadataEvent.KIND - ) { - Route.Channel(nip19.hex) - } else { - Route.EventRedirect(nip19.hex) - } + routeFor( + note = LocalCache.getOrCreateNote(nip19.hex), + loggedIn = account, + ) ?: Route.EventRedirect(nip19.hex) } is NAddress -> { - if (nip19.kind == CommunityDefinitionEvent.KIND) { - Route.Community(nip19.aTag()) - } else if (nip19.kind == LiveActivitiesEvent.KIND) { - Route.Channel(nip19.aTag()) - } else { - Route.EventRedirect(nip19.aTag()) - } + routeFor( + note = LocalCache.getOrCreateAddressableNote(nip19.address()), + loggedIn = account, + ) ?: Route.EventRedirect(nip19.aTag()) } is NEmbed -> { - if (LocalCache.getNoteIfExists(nip19.event.id) == null) { - LocalCache.verifyAndConsume(nip19.event, null) + val noteEvent = nip19.event + if (noteEvent is AddressableEvent) { + routeFor( + note = LocalCache.getOrCreateAddressableNote(noteEvent.address()), + loggedIn = account, + ) ?: Route.EventRedirect(noteEvent.addressTag()) + } else { + routeFor( + note = LocalCache.getOrCreateNote(nip19.event.id), + loggedIn = account, + ) ?: Route.EventRedirect(nip19.event.id) } - Route.EventRedirect(nip19.event.id) } else -> null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/StringResourceCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/StringResourceCache.kt index 91719a61fd..55430dd5f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/StringResourceCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/StringResourceCache.kt @@ -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,8 +22,11 @@ package com.vitorpamplona.amethyst.ui import android.content.Context import android.util.LruCache +import androidx.annotation.DrawableRes import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.LifecycleResumeEffect @@ -33,6 +36,9 @@ import androidx.lifecycle.compose.LifecycleResumeEffect private val resourceCache = LruCache(300) private var resourceCacheLanguage: String? = null +// Caches most common icons in the app to avoid using disk +private val iconCache = LruCache>(30) + fun checkLanguage(currentLanguage: String) { if (resourceCacheLanguage == null) { resourceCacheLanguage = currentLanguage @@ -118,3 +124,32 @@ fun stringRes( *args, ) } + +/** + * This cache can only be used if the painter is the only copy on the screen + * It should store a separate Painter for each size. It's safe to just assume + * Different compositions use different sizes. + */ +@Composable +fun painterRes( + @DrawableRes resourceId: Int, + sizeReference: Int, +): Painter { + val cached = iconCache.get(resourceId) + if (cached != null) { + val composition = cached.get(sizeReference) + if (composition != null) { + return composition + } + } + + val loaded = painterResource(resourceId) + + if (cached == null) { + iconCache.put(resourceId, LruCache(10)) + } else { + cached.put(sizeReference, loaded) + } + + return loaded +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/CrossfadeIfEnabled.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/CrossfadeIfEnabled.kt index 232b31a209..ec89215f0f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/CrossfadeIfEnabled.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/CrossfadeIfEnabled.kt @@ -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,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.actions +import androidx.collection.mutableScatterMapOf import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.Transition @@ -82,10 +83,7 @@ fun Transition.MyCrossfade( content: @Composable (targetState: T) -> Unit, ) { val currentlyVisible = remember { mutableStateListOf().apply { add(currentState) } } - val contentMap = - remember { - mutableMapOf Unit>() - } + val contentMap = remember { mutableScatterMapOf Unit>() } if (currentState == targetState) { // If not animating, just display the current state if (currentlyVisible.size != 1 || currentlyVisible[0] != targetState) { @@ -94,12 +92,11 @@ fun Transition.MyCrossfade( contentMap.clear() } } - if (!contentMap.contains(targetState)) { + + if (targetState !in contentMap) { // Replace target with the same key if any val replacementId = - currentlyVisible.indexOfFirst { - contentKey(it) == contentKey(targetState) - } + currentlyVisible.indexOfFirst { contentKey(it) == contentKey(targetState) } if (replacementId == -1) { currentlyVisible.add(targetState) } else { @@ -108,21 +105,16 @@ fun Transition.MyCrossfade( contentMap.clear() currentlyVisible.fastForEach { stateForContent -> contentMap[stateForContent] = { - val alpha by animateFloat( - transitionSpec = { animationSpec }, - ) { if (it == stateForContent) 1f else 0f } - Box(Modifier.graphicsLayer { this.alpha = alpha }, contentAlignment) { - content(stateForContent) - } + val alpha by + animateFloat(transitionSpec = { animationSpec }) { + if (it == stateForContent) 1f else 0f + } + Box(Modifier.graphicsLayer { this.alpha = alpha }, contentAlignment) { content(stateForContent) } } } } Box(modifier, contentAlignment) { - currentlyVisible.fastForEach { - key(contentKey(it)) { - contentMap[it]?.invoke() - } - } + currentlyVisible.fastForEach { key(contentKey(it)) { contentMap[it]?.invoke() } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt index 21438eb16c..66f922c901 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt @@ -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,8 +22,6 @@ package com.vitorpamplona.amethyst.ui.actions import androidx.compose.foundation.border import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -51,16 +49,11 @@ import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier @@ -72,7 +65,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextDirection @@ -84,31 +76,28 @@ import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.service.playback.composable.VideoView import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.components.BechLink import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.PostButton import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size5dp -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.amethyst.ui.theme.subtleBorder -import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -128,22 +117,11 @@ fun EditPostView( val scrollState = rememberScrollState() val scope = rememberCoroutineScope() - var showRelaysDialog by remember { mutableStateOf(false) } - var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() } LaunchedEffect(Unit) { postViewModel.load(edit, versionLookingAt, accountViewModel) } - DisposableEffect(Unit) { - NostrSearchEventOrUserDataSource.start() - - onDispose { - NostrSearchEventOrUserDataSource.clear() - NostrSearchEventOrUserDataSource.stop() - } - } - Dialog( onDismissRequest = { onClose() }, properties = @@ -153,70 +131,24 @@ fun EditPostView( decorFitsSystemWindows = false, ), ) { - if (showRelaysDialog) { - RelaySelectionDialog( - preSelectedList = relayList, - onClose = { showRelaysDialog = false }, - onPost = { relayList = it }, - accountViewModel = accountViewModel, - nav = nav, - ) - } - Scaffold( topBar = { - TopAppBar( - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer(modifier = StdHorzSpacer) - - Box { - IconButton( - modifier = Modifier.align(Alignment.Center), - onClick = { showRelaysDialog = true }, - ) { - Icon( - painter = painterResource(R.drawable.relays), - contentDescription = stringRes(id = R.string.relay_list_selector), - modifier = Modifier.height(25.dp), - tint = MaterialTheme.colorScheme.onBackground, - ) - } - } - PostButton( - onPost = { - postViewModel.sendPost(relayList = relayList) - scope.launch { - delay(100) - onClose() - } - }, - isActive = postViewModel.canPost(), - ) + PostingTopBar( + isActive = postViewModel::canPost, + onPost = { + postViewModel.sendPost() + scope.launch { + delay(100) + onClose() } }, - navigationIcon = { - Row { - Spacer(modifier = StdHorzSpacer) - CloseButton( - onPress = { - postViewModel.cancel() - scope.launch { - delay(100) - onClose() - } - }, - ) + onCancel = { + postViewModel.cancel() + scope.launch { + delay(100) + onClose() } }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), ) }, ) { pad -> @@ -352,11 +284,11 @@ fun EditPostView( ) { InvoiceRequest( lud16, - user.pubkeyHex, + user, accountViewModel, stringRes(id = R.string.lightning_invoice), stringRes(id = R.string.lightning_create_and_add_invoice), - onSuccess = { + onNewInvoice = { postViewModel.message = TextFieldValue(postViewModel.message.text + "\n\n" + it) postViewModel.wantsInvoice = false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt index 8489f7866b..59f877edee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt @@ -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 @@ -35,7 +35,6 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.service.uploads.MediaCompressor import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator @@ -44,10 +43,13 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.ammolite.relays.RelaySetupInfo import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.EmptyClientListener.onError +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder import com.vitorpamplona.quartz.nip94FileMetadata.alt @@ -120,18 +122,21 @@ open class EditPostViewModel : ViewModel() { editedFromNote = edit } - fun sendPost(relayList: List) { - viewModelScope.launch(Dispatchers.IO) { innerSendPost(relayList) } + fun sendPost() { + viewModelScope.launch(Dispatchers.IO) { innerSendPost() } } - suspend fun innerSendPost(relayList: List) { + suspend fun innerSendPost() { if (accountViewModel == null) { cancel() return } + val extraNotesToBroadcast = mutableListOf() + nip95attachments.forEach { - account?.sendNip95(it.first, it.second, relayList) + extraNotesToBroadcast.add(it.first) + extraNotesToBroadcast.add(it.second) } val notify = @@ -147,7 +152,7 @@ open class EditPostViewModel : ViewModel() { originalNote = editedFromNote!!, notify = notify, summary = subject.text.ifBlank { null }, - relayList = relayList, + extraNotesToBroadcast, ) cancel() @@ -165,6 +170,23 @@ open class EditPostViewModel : ViewModel() { server: ServerName, onError: (String, String) -> Unit, context: Context, + ) = try { + uploadUnsafe(alt, sensitiveContent, mediaQuality, isPrivate, server, onError, context) + } catch (e: SignerExceptions.ReadOnlyException) { + onError( + stringRes(context, R.string.read_only_user), + stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events), + ) + } + + fun uploadUnsafe( + alt: String?, + sensitiveContent: Boolean, + mediaQuality: Int, + isPrivate: Boolean = false, + server: ServerName, + onError: (String, String) -> Unit, + context: Context, ) { viewModelScope.launch { val myAccount = account ?: return@launch @@ -174,7 +196,6 @@ open class EditPostViewModel : ViewModel() { val results = myMultiOrchestrator.upload( - viewModelScope, alt, if (sensitiveContent) "" else null, MediaCompressor.intToCompressorQuality(mediaQuality), @@ -186,21 +207,21 @@ open class EditPostViewModel : ViewModel() { if (results.allGood) { results.successful.forEach { state -> if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) { - account?.createNip95( - state.result.bytes, - headerInfo = state.result.fileHeader, - alt, - if (sensitiveContent) "" else null, - ) { nip95 -> - nip95attachments = nip95attachments + nip95 - val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } + val nip95 = + myAccount.createNip95( + byteArray = state.result.bytes, + headerInfo = state.result.fileHeader, + alt = alt, + contentWarningReason = if (sensitiveContent) "" else null, + ) + nip95attachments = nip95attachments + nip95 + val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } - note?.let { - message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) - } - - urlPreview = findUrlInMessage() + note?.let { + message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) } + + urlPreview = findUrlInMessage() } else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { val iMeta = IMetaTagBuilder(state.result.url) @@ -251,8 +272,6 @@ open class EditPostViewModel : ViewModel() { userSuggestions?.reset() userSuggestionsMainMessage = null - - NostrSearchEventOrUserDataSource.clear() } open fun findUrlInMessage(): String? = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/InformationDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/InformationDialog.kt index df7c256b82..730ba7dbaf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/InformationDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/InformationDialog.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt deleted file mode 100644 index 300ec61640..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt +++ /dev/null @@ -1,455 +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.ui.actions - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Clear -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.FeatureSetType -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource -import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route -import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture -import com.vitorpamplona.amethyst.ui.note.SearchIcon -import com.vitorpamplona.amethyst.ui.note.UsernameDisplay -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelName -import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchBarViewModel -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.FeedPadding -import com.vitorpamplona.amethyst.ui.theme.Size20Modifier -import com.vitorpamplona.amethyst.ui.theme.Size55dp -import com.vitorpamplona.amethyst.ui.theme.placeholderText -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -@Composable -fun JoinUserOrChannelView( - onClose: () -> Unit, - accountViewModel: AccountViewModel, - nav: INav, -) { - val searchBarViewModel: SearchBarViewModel = - viewModel( - key = "SearchBarViewModel", - factory = - SearchBarViewModel.Factory( - accountViewModel.account, - ), - ) - - JoinUserOrChannelView( - searchBarViewModel = searchBarViewModel, - onClose = onClose, - accountViewModel = accountViewModel, - nav = nav, - ) -} - -@Composable -fun JoinUserOrChannelView( - searchBarViewModel: SearchBarViewModel, - onClose: () -> Unit, - accountViewModel: AccountViewModel, - nav: INav, -) { - Dialog( - onDismissRequest = { - NostrSearchEventOrUserDataSource.clear() - searchBarViewModel.clear() - onClose() - }, - properties = - DialogProperties( - dismissOnClickOutside = false, - ), - ) { - Surface { - Column( - modifier = - Modifier - .padding(10.dp) - .heightIn(min = 500.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - CloseButton( - onPress = { - searchBarViewModel.clear() - NostrSearchEventOrUserDataSource.clear() - onClose() - }, - ) - - Text( - text = stringRes(R.string.channel_list_join_conversation), - fontWeight = FontWeight.Bold, - ) - - Text( - text = "", - color = MaterialTheme.colorScheme.placeholderText, - fontWeight = FontWeight.Bold, - ) - } - - Spacer(modifier = Modifier.height(15.dp)) - - RenderSearch(searchBarViewModel, accountViewModel, nav) - } - } - } -} - -@Composable -private fun RenderSearch( - searchBarViewModel: SearchBarViewModel, - accountViewModel: AccountViewModel, - nav: INav, -) { - val listState = rememberLazyListState() - - val lifeCycleOwner = LocalLifecycleOwner.current - - // Create a channel for processing search queries. - val searchTextChanges = remember { Channel(Channel.CONFLATED) } - - LaunchedEffect(Unit) { - launch(Dispatchers.IO) { - LocalCache.live.newEventBundles.collect { - checkNotInMainThread() - if (searchBarViewModel.isSearchingFun()) { - searchBarViewModel.invalidateData() - } - } - } - } - - LaunchedEffect(Unit) { - // Wait for text changes to stop for 300 ms before firing off search. - withContext(Dispatchers.IO) { - searchTextChanges - .receiveAsFlow() - .filter { it.isNotBlank() } - .distinctUntilChanged() - .debounce(300) - .collectLatest { - if (it.length >= 2) { - NostrSearchEventOrUserDataSource.search(it.trim()) - } - - searchBarViewModel.invalidateData() - - // makes sure to show the top of the search - launch(Dispatchers.Main) { listState.animateScrollToItem(0) } - } - } - } - - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Join Start") - NostrSearchEventOrUserDataSource.start() - searchBarViewModel.invalidateData() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Join Stop") - NostrSearchEventOrUserDataSource.clear() - NostrSearchEventOrUserDataSource.stop() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } - - // LAST ROW - SearchEditTextForJoin(searchBarViewModel, searchTextChanges) - - RenderSearchResults(searchBarViewModel, listState, accountViewModel, nav) -} - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -private fun SearchEditTextForJoin( - searchBarViewModel: SearchBarViewModel, - searchTextChanges: Channel, -) { - val scope = rememberCoroutineScope() - - // initialize focus reference to be able to request focus programmatically - val focusRequester = remember { FocusRequester() } - val keyboardController = LocalSoftwareKeyboardController.current - - LaunchedEffect(Unit) { - launch { - delay(100) - focusRequester.requestFocus() - } - } - - Row( - modifier = - Modifier - .padding(horizontal = 10.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - label = { Text(text = stringRes(R.string.channel_list_user_or_group_id)) }, - value = searchBarViewModel.searchValue, - onValueChange = { - searchBarViewModel.updateSearchValue(it) - scope.launch(Dispatchers.IO) { searchTextChanges.trySend(it) } - }, - leadingIcon = { SearchIcon(modifier = Size20Modifier, MaterialTheme.colorScheme.placeholderText) }, - modifier = - Modifier - .weight(1f, true) - .defaultMinSize(minHeight = 20.dp) - .focusRequester(focusRequester) - .onFocusChanged { - if (it.isFocused) { - keyboardController?.show() - } - }, - placeholder = { - Text( - text = stringRes(R.string.channel_list_user_or_group_id_demo), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - trailingIcon = { - if (searchBarViewModel.isSearching) { - IconButton( - onClick = { - searchBarViewModel.clear() - NostrSearchEventOrUserDataSource.clear() - }, - ) { - Icon( - imageVector = Icons.Default.Clear, - contentDescription = stringRes(R.string.clear), - ) - } - } - }, - ) - } -} - -@Composable -private fun RenderSearchResults( - searchBarViewModel: SearchBarViewModel, - listState: LazyListState, - accountViewModel: AccountViewModel, - nav: INav, -) { - if (searchBarViewModel.isSearching) { - val users by searchBarViewModel.searchResultsUsers.collectAsStateWithLifecycle() - val channels by searchBarViewModel.searchResultsChannels.collectAsStateWithLifecycle() - - Row( - modifier = - Modifier - .fillMaxWidth() - .fillMaxHeight() - .padding(vertical = 10.dp), - ) { - LazyColumn( - modifier = Modifier.fillMaxHeight(), - contentPadding = FeedPadding, - state = listState, - ) { - itemsIndexed( - users, - key = { _, item -> "u" + item.pubkeyHex }, - ) { _, item -> - UserComposeForChat(item, accountViewModel) { - accountViewModel.createChatRoomFor(item) { nav.nav(Route.Room(it)) } - - searchBarViewModel.clear() - } - - HorizontalDivider( - thickness = DividerThickness, - ) - } - - itemsIndexed( - channels, - key = { _, item -> "c" + item.idHex }, - ) { _, item -> - RenderChannel( - item, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, - loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, - ) { - nav.nav(Route.Channel(item.idHex)) - searchBarViewModel.clear() - } - - HorizontalDivider( - thickness = DividerThickness, - ) - } - } - } - } -} - -@Composable -private fun RenderChannel( - item: com.vitorpamplona.amethyst.model.Channel, - loadProfilePicture: Boolean, - loadRobohash: Boolean, - onClick: () -> Unit, -) { - ChannelName( - channelIdHex = item.idHex, - channelPicture = item.profilePicture(), - channelTitle = { - Text( - item.toBestDisplayName(), - fontWeight = FontWeight.Bold, - ) - }, - channelLastTime = null, - channelLastContent = item.summary(), - hasNewMessages = false, - onClick = onClick, - loadProfilePicture = loadProfilePicture, - loadRobohash = loadRobohash, - ) -} - -@Composable -fun UserComposeForChat( - baseUser: User, - accountViewModel: AccountViewModel, - onClick: () -> Unit, -) { - Row( - modifier = - Modifier - .clickable( - onClick = onClick, - ).padding( - start = 12.dp, - end = 12.dp, - top = 10.dp, - bottom = 10.dp, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - ClickableUserPicture(baseUser, Size55dp, accountViewModel) - - Column( - modifier = - Modifier - .padding(start = 10.dp) - .weight(1f), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(baseUser, accountViewModel = accountViewModel) } - - DisplayUserAboutInfo(baseUser) - } - } -} - -@Composable -private fun DisplayUserAboutInfo(baseUser: User) { - val baseUserState by baseUser.live().metadata.observeAsState() - val about by remember(baseUserState) { derivedStateOf { baseUserState?.user?.info?.about ?: "" } } - - Text( - text = about, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt index 655f038766..8a02c884f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt @@ -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,19 +27,19 @@ import android.media.MediaScannerConnection import android.os.Build import android.os.Environment import android.provider.MediaStore +import android.util.Log import android.webkit.MimeTypeMap import androidx.annotation.RequiresApi import androidx.core.net.toFile import androidx.core.net.toUri -import com.vitorpamplona.amethyst.BuildConfig +import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk.PICTURES_SUBDIRECTORY import kotlinx.coroutines.CancellationException -import okhttp3.Call -import okhttp3.Callback +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.Response +import okhttp3.coroutines.executeAsync import okio.BufferedSource -import okio.IOException import okio.buffer import okio.sink import okio.source @@ -47,16 +47,16 @@ import java.io.File import java.util.UUID object MediaSaverToDisk { - fun saveDownloadingIfNeeded( + suspend fun saveDownloadingIfNeeded( videoUri: String?, okHttpClient: (String) -> OkHttpClient, mimeType: String?, localContext: Context, onSuccess: () -> Any?, onError: (Throwable) -> Any?, - ) { + ) = withContext(Dispatchers.IO) { when { - videoUri.isNullOrBlank() -> return + videoUri.isNullOrBlank() -> return@withContext videoUri.startsWith("file") -> save( localFile = videoUri.toUri().toFile(), @@ -82,7 +82,7 @@ object MediaSaverToDisk { * * @see PICTURES_SUBDIRECTORY */ - fun downloadAndSave( + suspend fun downloadAndSave( url: String, mimeType: String?, okHttpClient: (String) -> OkHttpClient, @@ -91,67 +91,50 @@ object MediaSaverToDisk { onError: (Throwable) -> Any?, ) { val client = okHttpClient(url) - val request = Request .Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .get() .url(url) .build() - client - .newCall(request) - .enqueue( - object : Callback { - override fun onFailure( - call: Call, - e: IOException, - ) { - e.printStackTrace() - onError(e) - } + try { + client.newCall(request).executeAsync().use { response -> + withContext(Dispatchers.IO) { + check(response.isSuccessful) - override fun onResponse( - call: Call, - response: Response, - ) { - try { - check(response.isSuccessful) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val contentType = response.header("Content-Type") ?: getMimeTypeFromExtension(trimInlineMetaData(url)) + check(contentType.isNotBlank()) { "Can't find out the content type" } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val contentType = response.header("Content-Type") ?: getMimeTypeFromExtension(trimInlineMetaData(url)) - check(contentType.isNotBlank()) { "Can't find out the content type" } - - val realType = - if (contentType == "application/octet-stream") { - mimeType ?: getMimeTypeFromExtension(url) - } else { - contentType - } - - saveContentQ( - displayName = File(trimInlineMetaData(url)).nameWithoutExtension, - contentType = realType, - contentSource = response.body.source(), - contentResolver = context.contentResolver, - ) + val realType = + if (contentType == "application/octet-stream") { + mimeType ?: getMimeTypeFromExtension(url) } else { - saveContentDefault( - fileName = File(trimInlineMetaData(url)).name, - contentSource = response.body.source(), - context = context, - ) + contentType } - onSuccess() - } catch (e: Exception) { - if (e is CancellationException) throw e - e.printStackTrace() - onError(e) - } + + saveContentQ( + displayName = File(trimInlineMetaData(url)).nameWithoutExtension, + contentType = realType, + contentSource = response.body.source(), + contentResolver = context.contentResolver, + ) + } else { + saveContentDefault( + fileName = File(trimInlineMetaData(url)).name, + contentSource = response.body.source(), + context = context, + ) } - }, - ) + onSuccess() + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("MediaSaverToDisk", "Error parsing response", e) + onError(e) + } } private fun getMimeTypeFromExtension(fileName: String): String = @@ -188,7 +171,7 @@ object MediaSaverToDisk { onSuccess() } catch (e: Exception) { if (e is CancellationException) throw e - e.printStackTrace() + Log.w("MediaSaverToDisk", "Unable to save", e) onError(e) } } @@ -200,10 +183,11 @@ object MediaSaverToDisk { contentSource: BufferedSource, contentResolver: ContentResolver, ) { + val cleanMimeType = contentType.substringBefore(";").trim() val contentValues = ContentValues().apply { put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) - put(MediaStore.MediaColumns.MIME_TYPE, contentType) + put(MediaStore.MediaColumns.MIME_TYPE, cleanMimeType) put( MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + File.separatorChar + PICTURES_SUBDIRECTORY, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt index 2f096d38e3..630f525738 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt @@ -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 @@ -39,14 +39,11 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.ammolite.relays.RelaySetupInfo +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withTimeoutOrNull -import kotlin.coroutines.resume @Stable open class NewMediaModel : ViewModel() { @@ -82,13 +79,24 @@ open class NewMediaModel : ViewModel() { fun upload( context: Context, - relayList: List, + onSucess: () -> Unit, + onError: (String, String) -> Unit, + ) = try { + uploadUnsafe(context, onSucess, onError) + } catch (e: SignerExceptions.ReadOnlyException) { + onError( + stringRes(context, R.string.read_only_user), + stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events), + ) + } + + fun uploadUnsafe( + context: Context, onSucess: () -> Unit, onError: (String, String) -> Unit, ) { viewModelScope.launch { val myAccount = account ?: return@launch - if (relayList.isEmpty()) return@launch val serverToUse = selectedServer ?: return@launch val myMultiOrchestrator = multiOrchestrator ?: return@launch @@ -97,7 +105,6 @@ open class NewMediaModel : ViewModel() { val results = myMultiOrchestrator.upload( - viewModelScope, caption, if (sensitiveContent) "" else null, MediaCompressor.intToCompressorQuality(mediaQualitySlider), @@ -138,14 +145,8 @@ open class NewMediaModel : ViewModel() { nip95s.map { // upload each file as an individual nip95 event. viewModelScope.launch(Dispatchers.IO) { - withTimeoutOrNull(30000) { - suspendCancellableCoroutine { continuation -> - account?.createNip95(it.bytes, headerInfo = it.fileHeader, caption, if (sensitiveContent) "" else null) { nip95 -> - account?.consumeAndSendNip95(nip95.first, nip95.second, relayList) - continuation.resume(true) - } - } - } + val nip95 = myAccount.createNip95(it.bytes, headerInfo = it.fileHeader, caption, if (sensitiveContent) "" else null) + myAccount.consumeAndSendNip95(nip95.first, nip95.second) } } @@ -153,21 +154,14 @@ open class NewMediaModel : ViewModel() { videosAndOthers.map { // upload each file as an individual nip95 event. viewModelScope.launch(Dispatchers.IO) { - withTimeoutOrNull(30000) { - suspendCancellableCoroutine { continuation -> - account?.sendHeader( - it.url, - it.magnet, - it.fileHeader, - caption, - if (sensitiveContent) "" else null, - it.uploadedHash, - relayList, - ) { - continuation.resume(true) - } - } - } + account?.sendHeader( + url = it.url, + magnetUri = it.magnet, + headerInfo = it.fileHeader, + alt = caption, + contentWarningReason = if (sensitiveContent) "" else null, + originalHash = it.uploadedHash, + ) } } @@ -175,18 +169,11 @@ open class NewMediaModel : ViewModel() { if (imageUrls.isNotEmpty()) { listOf( viewModelScope.launch(Dispatchers.IO) { - withTimeoutOrNull(30000) { - suspendCancellableCoroutine { continuation -> - account?.sendAllAsOnePictureEvent( - imageUrls, - caption, - if (sensitiveContent) "" else null, - relayList, - ) { - continuation.resume(true) - } - } - } + account?.sendAllAsOnePictureEvent( + urlHeaderInfo = imageUrls, + caption = caption, + contentWarningReason = if (sensitiveContent) "" else null, + ) }, ) } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt index 7a68a7d40a..643a72ace7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt @@ -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,8 +23,6 @@ package com.vitorpamplona.amethyst.ui.actions import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -35,28 +33,21 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -67,17 +58,15 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.SettingSwitchItem import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.PostButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size5dp -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -100,9 +89,6 @@ fun NewMediaView( postViewModel.load(account, uris) } - var showRelaysDialog by remember { mutableStateOf(false) } - var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() } - Dialog( onDismissRequest = { onClose() }, properties = @@ -115,70 +101,23 @@ fun NewMediaView( SetDialogToEdgeToEdge() Scaffold( topBar = { - TopAppBar( - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer(modifier = StdHorzSpacer) - - Box { - IconButton( - modifier = Modifier.align(Alignment.Center), - onClick = { showRelaysDialog = true }, - ) { - Icon( - painter = painterResource(R.drawable.relays), - contentDescription = stringRes(id = R.string.relay_list_selector), - modifier = Modifier.height(25.dp), - tint = MaterialTheme.colorScheme.onBackground, - ) - } + PostingTopBar( + isActive = postViewModel::canPost, + onCancel = { + postViewModel.cancelModel() + onClose() + }, + onPost = { + postViewModel.upload(context, onClose, accountViewModel.toastManager::toast) + postViewModel.selectedServer?.let { + if (it.type != ServerType.NIP95) { + account.settings.changeDefaultFileServer(it) } - - PostButton( - onPost = { - postViewModel.upload(context, relayList, onClose, accountViewModel.toastManager::toast) - postViewModel.selectedServer?.let { - if (it.type != ServerType.NIP95) { - account.settings.changeDefaultFileServer(it) - } - } - }, - isActive = postViewModel.canPost(), - ) } }, - navigationIcon = { - Row { - Spacer(modifier = StdHorzSpacer) - CloseButton( - onPress = { - postViewModel.cancelModel() - onClose() - }, - ) - } - }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), ) }, ) { pad -> - if (showRelaysDialog) { - RelaySelectionDialog( - preSelectedList = relayList, - onClose = { showRelaysDialog = false }, - onPost = { relayList = it }, - accountViewModel = accountViewModel, - nav = nav, - ) - } - Surface( modifier = Modifier @@ -186,8 +125,16 @@ fun NewMediaView( .consumeWindowInsets(pad) .imePadding(), ) { - Column(Modifier.fillMaxSize().padding(start = 10.dp, end = 10.dp, bottom = 10.dp)) { - Column(Modifier.fillMaxWidth().verticalScroll(scrollState)) { + Column( + Modifier + .fillMaxSize() + .padding(start = 10.dp, end = 10.dp, bottom = 10.dp), + ) { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(scrollState), + ) { ImageVideoPost(postViewModel, accountViewModel) } } @@ -202,7 +149,8 @@ fun ImageVideoPost( accountViewModel: AccountViewModel, ) { val nip95description = stringRes(id = R.string.upload_server_relays_nip95) - val fileServers by accountViewModel.account.liveServerList.collectAsState() + val fileServers by accountViewModel.account.serverLists.liveServerList + .collectAsState() val fileServerOptions = remember(fileServers) { @@ -226,7 +174,11 @@ fun ImageVideoPost( OutlinedTextField( label = { Text(text = stringRes(R.string.add_caption)) }, - modifier = Modifier.fillMaxWidth().padding(top = 3.dp).height(150.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(top = 3.dp) + .height(150.dp), maxLines = 10, value = postViewModel.caption, onValueChange = { postViewModel.caption = it }, @@ -245,7 +197,10 @@ fun ImageVideoPost( SettingSwitchItem( title = R.string.add_sensitive_content_label, description = R.string.add_sensitive_content_description, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp), checked = postViewModel.sensitiveContent, onCheckedChange = { postViewModel.sensitiveContent = it }, ) @@ -264,7 +219,10 @@ fun ImageVideoPost( } Column( - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(Size5dp), ) { Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt index 4b7eb7bdcf..9ac7dfe6bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt @@ -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,10 +21,12 @@ package com.vitorpamplona.amethyst.ui.actions import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes @@ -42,7 +44,6 @@ class NewMessageTagger( var message: String, var pTags: List? = null, var eTags: List? = null, - var channelHex: String? = null, var dao: Dao, ) { val directMentions = mutableSetOf() @@ -73,12 +74,12 @@ class NewMessageTagger( is NPub -> addUserToMentions(dao.getOrCreateUser(entity.hex)) is NProfile -> addUserToMentions(dao.getOrCreateUser(entity.hex)) - is com.vitorpamplona.quartz.nip19Bech32.entities.Note -> addNoteToReplyTos(dao.getOrCreateNote(entity.hex)) + is com.vitorpamplona.quartz.nip19Bech32.entities.NNote -> addNoteToReplyTos(dao.getOrCreateNote(entity.hex)) is NEvent -> addNoteToReplyTos(dao.getOrCreateNote(entity.hex)) is NEmbed -> addNoteToReplyTos(dao.getOrCreateNote(entity.event.id)) is NAddress -> { - val note = dao.checkGetOrCreateAddressableNote(entity.aTag()) + val note = dao.getOrCreateAddressableNote(entity.address()) if (note != null) { addNoteToReplyTos(note) } @@ -107,7 +108,7 @@ class NewMessageTagger( getNostrAddress(dao.getOrCreateUser(entity.hex).toNProfile(), results.restOfWord) } - is com.vitorpamplona.quartz.nip19Bech32.entities.Note -> { + is com.vitorpamplona.quartz.nip19Bech32.entities.NNote -> { getNostrAddress(dao.getOrCreateNote(entity.hex).toNEvent(), results.restOfWord) } is NEvent -> { @@ -115,9 +116,9 @@ class NewMessageTagger( } is NAddress -> { - val note = dao.checkGetOrCreateAddressableNote(entity.aTag()) + val note = dao.getOrCreateAddressableNote(entity.address()) if (note != null) { - getNostrAddress(note.idNote(), results.restOfWord) + getNostrAddress(note.toNAddr(), results.restOfWord) } else { word } @@ -235,5 +236,5 @@ interface Dao { suspend fun getOrCreateNote(hex: String): Note - suspend fun checkGetOrCreateAddressableNote(hex: String): Note? + suspend fun getOrCreateAddressableNote(address: Address): AddressableNote? } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt index 0f146f7b75..e4aa08783f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt @@ -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,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.components +import android.annotation.SuppressLint import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -40,13 +41,13 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.placeholderText import kotlinx.coroutines.CancellationException @Composable -fun NewPollClosing(pollViewModel: NewPostViewModel) { +fun NewPollClosing(pollViewModel: ShortNotePostViewModel) { var text by rememberSaveable { mutableStateOf("") } pollViewModel.isValidClosedAt.value = true @@ -101,8 +102,9 @@ fun NewPollClosing(pollViewModel: NewPostViewModel) { } } +@SuppressLint("ViewModelConstructorInComposable") @Preview @Composable fun NewPollClosingPreview() { - NewPollClosing(NewPostViewModel()) + NewPollClosing(ShortNotePostViewModel()) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollConsensusThreshold.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollConsensusThreshold.kt index 277b89d761..4ae159cd6c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollConsensusThreshold.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollConsensusThreshold.kt @@ -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,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.components +import android.annotation.SuppressLint import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -40,13 +41,13 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.placeholderText import kotlinx.coroutines.CancellationException @Composable -fun NewPollConsensusThreshold(pollViewModel: NewPostViewModel) { +fun NewPollConsensusThreshold(pollViewModel: ShortNotePostViewModel) { var text by rememberSaveable { mutableStateOf("") } pollViewModel.isValidConsensusThreshold.value = true @@ -101,8 +102,9 @@ fun NewPollConsensusThreshold(pollViewModel: NewPostViewModel) { } } +@SuppressLint("ViewModelConstructorInComposable") @Preview @Composable fun NewPollConsensusThresholdPreview() { - NewPollConsensusThreshold(NewPostViewModel()) + NewPollConsensusThreshold(ShortNotePostViewModel()) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt index a630d31575..d13bb567e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt @@ -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,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.actions +import android.annotation.SuppressLint import androidx.compose.foundation.layout.Row import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons @@ -34,12 +35,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable fun NewPollOption( - pollViewModel: NewPostViewModel, + pollViewModel: ShortNotePostViewModel, optionIndex: Int, ) { Row { @@ -85,8 +87,9 @@ fun NewPollOption( } } +@SuppressLint("ViewModelConstructorInComposable") @Preview @Composable fun NewPollOptionPreview() { - NewPollOption(NewPostViewModel(), 0) + NewPollOption(ShortNotePostViewModel(), 0) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollPrimaryDescription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollPrimaryDescription.kt index d38031192e..3cbe92a9b2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollPrimaryDescription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollPrimaryDescription.kt @@ -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 @@ -41,14 +41,14 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.placeholderText @OptIn(ExperimentalComposeUiApi::class) @Composable -fun NewPollPrimaryDescription(pollViewModel: NewPostViewModel) { +fun NewPollPrimaryDescription(pollViewModel: ShortNotePostViewModel) { // initialize focus reference to be able to request focus programmatically val focusRequester = remember { FocusRequester() } val keyboardController = LocalSoftwareKeyboardController.current diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt index e575252319..0f522db2c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt @@ -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 @@ -28,13 +28,13 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable fun NewPollRecipientsField( - pollViewModel: NewPostViewModel, + pollViewModel: ShortNotePostViewModel, account: Account, ) { // if no recipients, add user's pubkey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt index a49b488daf..1f97a05994 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt @@ -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,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.actions +import android.annotation.SuppressLint import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -38,12 +39,13 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable -fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) { +fun NewPollVoteValueRange(pollViewModel: ShortNotePostViewModel) { val colorInValid = OutlinedTextFieldDefaults.colors( focusedBorderColor = MaterialTheme.colorScheme.error, @@ -64,7 +66,7 @@ fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) { onValueChange = { pollViewModel.updateMinZapAmountForPoll(it) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.weight(1f), - colors = if (pollViewModel.isValidvalueMinimum.value) colorValid else colorInValid, + colors = if (pollViewModel.isValidValueMinimum.value) colorValid else colorInValid, label = { Text( text = stringRes(R.string.poll_zap_value_min), @@ -86,7 +88,7 @@ fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) { onValueChange = { pollViewModel.updateMaxZapAmountForPoll(it) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.weight(1f), - colors = if (pollViewModel.isValidvalueMaximum.value) colorValid else colorInValid, + colors = if (pollViewModel.isValidValueMaximum.value) colorValid else colorInValid, label = { Text( text = stringRes(R.string.poll_zap_value_max), @@ -114,12 +116,13 @@ fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) { } } +@SuppressLint("ViewModelConstructorInComposable") @Preview @Composable fun NewPollVoteValueRangePreview() { Column( modifier = Modifier.fillMaxWidth(), ) { - NewPollVoteValueRange(NewPostViewModel()) + NewPollVoteValueRange(ShortNotePostViewModel()) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt deleted file mode 100644 index 76fd0075a3..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ /dev/null @@ -1,1365 +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.ui.actions - -import android.R.attr.category -import android.content.Context -import android.util.Log -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.SnapshotStateMap -import androidx.compose.ui.text.input.TextFieldValue -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.compose.currentWord -import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor -import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord -import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.imageExtensions -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource -import com.vitorpamplona.amethyst.service.location.LocationState -import com.vitorpamplona.amethyst.service.uploads.MediaCompressor -import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator -import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator -import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName -import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia -import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState -import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber -import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField -import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewState -import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState -import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.IZapRaiser -import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.IZapField -import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.SplitBuilder -import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.toZapSplitSetup -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.ammolite.relays.RelaySetupInfo -import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent -import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent -import com.vitorpamplona.quartz.experimental.zapPolls.closedAt -import com.vitorpamplona.quartz.experimental.zapPolls.consensusThreshold -import com.vitorpamplona.quartz.experimental.zapPolls.maxAmount -import com.vitorpamplona.quartz.experimental.zapPolls.minAmount -import com.vitorpamplona.quartz.experimental.zapPolls.tags.PollOptionTag -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.tags.events.ETag -import com.vitorpamplona.quartz.nip01Core.tags.events.eTags -import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash -import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags -import com.vitorpamplona.quartz.nip01Core.tags.people.pTags -import com.vitorpamplona.quartz.nip01Core.tags.references.references -import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip10Notes.content.findHashtags -import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris -import com.vitorpamplona.quartz.nip10Notes.content.findURLs -import com.vitorpamplona.quartz.nip10Notes.tags.notify -import com.vitorpamplona.quartz.nip10Notes.tags.positionalMarkedTags -import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes -import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip22Comments.RootScope -import com.vitorpamplona.quartz.nip22Comments.notify -import com.vitorpamplona.quartz.nip28PublicChat.base.notify -import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag -import com.vitorpamplona.quartz.nip30CustomEmoji.emojis -import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent -import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent -import com.vitorpamplona.quartz.nip34Git.reply.notify -import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent -import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent -import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning -import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive -import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW -import com.vitorpamplona.quartz.nip37Drafts.DraftEvent -import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent -import com.vitorpamplona.quartz.nip53LiveActivities.chat.notify -import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup -import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupLnAddress -import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup -import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits -import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser -import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount -import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent -import com.vitorpamplona.quartz.nip73ExternalIds.GeohashId -import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder -import com.vitorpamplona.quartz.nip92IMeta.imetas -import com.vitorpamplona.quartz.nip94FileMetadata.alt -import com.vitorpamplona.quartz.nip94FileMetadata.blurhash -import com.vitorpamplona.quartz.nip94FileMetadata.dims -import com.vitorpamplona.quartz.nip94FileMetadata.hash -import com.vitorpamplona.quartz.nip94FileMetadata.magnet -import com.vitorpamplona.quartz.nip94FileMetadata.mimeType -import com.vitorpamplona.quartz.nip94FileMetadata.originalHash -import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent -import com.vitorpamplona.quartz.nip94FileMetadata.size -import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent -import com.vitorpamplona.quartz.nip99Classifieds.tags.ConditionTag -import com.vitorpamplona.quartz.nip99Classifieds.tags.PriceTag -import kotlinx.collections.immutable.ImmutableList -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.util.UUID - -enum class UserSuggestionAnchor { - MAIN_MESSAGE, - FORWARD_ZAPS, - TO_USERS, -} - -@Stable -open class NewPostViewModel : - ViewModel(), - ILocationGrabber, - IMessageField, - IZapField, - IZapRaiser { - var draftTag: String by mutableStateOf(UUID.randomUUID().toString()) - - var accountViewModel: AccountViewModel? = null - var account: Account? = null - - var originalNote: Note? by mutableStateOf(null) - var forkedFromNote: Note? by mutableStateOf(null) - - var pTags by mutableStateOf?>(null) - var eTags by mutableStateOf?>(null) - - val iMetaAttachments = IMetaAttachments() - var nip95attachments by - mutableStateOf>>(emptyList()) - - override var message by mutableStateOf(TextFieldValue("")) - - val urlPreviews = PreviewState() - - var isUploadingImage by mutableStateOf(false) - - var userSuggestions: UserSuggestionState? = null - var userSuggestionsMainMessage: UserSuggestionAnchor? = null - - var emojiSuggestions: EmojiSuggestionState? = null - - // Images and Videos - var multiOrchestrator by mutableStateOf(null) - - // Polls - var canUsePoll by mutableStateOf(false) - var wantsPoll by mutableStateOf(false) - var zapRecipients = mutableStateListOf() - var pollOptions = newStateMapPollOptions() - var valueMaximum by mutableStateOf(null) - var valueMinimum by mutableStateOf(null) - var consensusThreshold: Int? = null - var closedAt: Long? = null - - var isValidRecipients = mutableStateOf(true) - var isValidvalueMaximum = mutableStateOf(true) - var isValidvalueMinimum = mutableStateOf(true) - var isValidConsensusThreshold = mutableStateOf(true) - var isValidClosedAt = mutableStateOf(true) - - // Classifieds - var wantsProduct by mutableStateOf(false) - var title by mutableStateOf(TextFieldValue("")) - var price by mutableStateOf(TextFieldValue("")) - var locationText by mutableStateOf(TextFieldValue("")) - var category by mutableStateOf(TextFieldValue("")) - var condition by mutableStateOf(ConditionTag.CONDITION.USED_LIKE_NEW) - - // Invoices - var canAddInvoice by mutableStateOf(false) - var wantsInvoice by mutableStateOf(false) - - var wantsSecretEmoji by mutableStateOf(false) - - // Forward Zap to - var wantsForwardZapTo by mutableStateOf(false) - override var forwardZapTo = mutableStateOf>(SplitBuilder()) - override var forwardZapToEditting = mutableStateOf(TextFieldValue("")) - - // NSFW, Sensitive - var wantsToMarkAsSensitive by mutableStateOf(false) - - // GeoHash - var wantsToAddGeoHash by mutableStateOf(false) - var location: StateFlow? = null - var wantsExclusiveGeoPost by mutableStateOf(false) - - // ZapRaiser - var canAddZapRaiser by mutableStateOf(false) - var wantsZapraiser by mutableStateOf(false) - override val zapRaiserAmount = mutableStateOf(null) - - val draftTextChanges = Channel(Channel.CONFLATED) - - fun lnAddress(): String? = account?.userProfile()?.info?.lnAddress() - - fun hasLnAddress(): Boolean = account?.userProfile()?.info?.lnAddress() != null - - fun user(): User? = account?.userProfile() - - open fun init(accountVM: AccountViewModel) { - this.accountViewModel = accountVM - this.account = accountVM.account - this.canAddInvoice = hasLnAddress() - this.canAddZapRaiser = hasLnAddress() - - this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM) - - this.emojiSuggestions?.reset() - this.emojiSuggestions = EmojiSuggestionState(accountVM) - } - - open fun load( - replyingTo: Note?, - quote: Note?, - fork: Note?, - version: Note?, - draft: Note?, - ) { - val accountViewModel = accountViewModel ?: return - val noteEvent = draft?.event - val noteAuthor = draft?.author - - if (draft != null && noteEvent is DraftEvent && noteAuthor != null) { - viewModelScope.launch(Dispatchers.IO) { - accountViewModel.createTempDraftNote(noteEvent) { innerNote -> - if (innerNote != null) { - val oldTag = (draft.event as? AddressableEvent)?.dTag() - if (oldTag != null) { - draftTag = oldTag - } - loadFromDraft(innerNote, accountViewModel) - } - } - } - } else { - originalNote = replyingTo - replyingTo?.let { replyNote -> - if (replyNote.event is BaseThreadedEvent) { - this.eTags = (replyNote.replyTo ?: emptyList()).plus(replyNote) - } else { - this.eTags = listOf(replyNote) - } - - if (replyNote.event !is CommunityDefinitionEvent) { - replyNote.author?.let { replyUser -> - val currentMentions = - (replyNote.event as? TextNoteEvent) - ?.mentions() - ?.map { LocalCache.getOrCreateUser(it.pubKey) } - ?: emptyList() - - if (currentMentions.contains(replyUser)) { - this.pTags = currentMentions - } else { - this.pTags = currentMentions.plus(replyUser) - } - } - } - } - ?: run { - eTags = null - pTags = null - } - - canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null - canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null - canUsePoll = originalNote == null - multiOrchestrator = null - - quote?.let { - message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}") - - it.author?.let { quotedUser -> - if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) { - if (forwardZapTo.value.items.none { it.key.pubkeyHex == quotedUser.pubkeyHex }) { - forwardZapTo.value.addItem(quotedUser) - } - if (forwardZapTo.value.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) { - forwardZapTo.value.addItem(accountViewModel.userProfile()) - } - - val pos = forwardZapTo.value.items.indexOfFirst { it.key.pubkeyHex == quotedUser.pubkeyHex } - forwardZapTo.value.updatePercentage(pos, 0.9f) - } - } - } - - fork?.let { - message = TextFieldValue(version?.event?.content ?: it.event?.content ?: "") - - it.event?.isSensitiveOrNSFW()?.let { - if (it) wantsToMarkAsSensitive = true - } - - it.event?.zapraiserAmount()?.let { - zapRaiserAmount.value = it - } - - it.event?.zapSplitSetup()?.let { - val totalWeight = it.sumOf { if (it is ZapSplitSetupLnAddress) 0.0 else it.weight } - - it.forEach { - if (it is ZapSplitSetup) { - forwardZapTo.value.addItem(LocalCache.getOrCreateUser(it.pubKeyHex), (it.weight / totalWeight).toFloat()) - } - } - } - - // Only adds if it is not already set up. - if (forwardZapTo.value.items.isEmpty()) { - it.author?.let { forkedAuthor -> - if (forkedAuthor.pubkeyHex != accountViewModel.userProfile().pubkeyHex) { - if (forwardZapTo.value.items.none { it.key.pubkeyHex == forkedAuthor.pubkeyHex }) forwardZapTo.value.addItem(forkedAuthor) - if (forwardZapTo.value.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) forwardZapTo.value.addItem(accountViewModel.userProfile()) - - val pos = forwardZapTo.value.items.indexOfFirst { it.key.pubkeyHex == forkedAuthor.pubkeyHex } - forwardZapTo.value.updatePercentage(pos, 0.8f) - } - } - } - - it.author?.let { - if (this.pTags == null) { - this.pTags = listOf(it) - } else if (this.pTags?.contains(it) != true) { - this.pTags = listOf(it) + (this.pTags ?: emptyList()) - } - } - - forkedFromNote = it - } ?: run { - forkedFromNote = null - } - - if (!forwardZapTo.value.items.isEmpty()) { - wantsForwardZapTo = true - } - } - - urlPreviews.update(message) - } - - private fun loadFromDraft( - draft: Note, - accountViewModel: AccountViewModel, - ) { - Log.d("draft", draft.event!!.toJson()) - val draftEvent = draft.event ?: return - - canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null - canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null - multiOrchestrator = null - - val localfowardZapTo = draftEvent.tags.filter { it.size > 1 && it[0] == "zap" } - forwardZapTo.value = SplitBuilder() - localfowardZapTo.forEach { - val user = LocalCache.getOrCreateUser(it[1]) - val value = it.last().toFloatOrNull() ?: 0f - forwardZapTo.value.addItem(user, value) - } - forwardZapToEditting.value = TextFieldValue("") - wantsForwardZapTo = localfowardZapTo.isNotEmpty() - - wantsToMarkAsSensitive = draftEvent.isSensitive() - - val geohash = draftEvent.getGeoHash() - wantsToAddGeoHash = geohash != null - if (geohash != null) { - wantsExclusiveGeoPost = draftEvent.kind == CommentEvent.KIND - } - - val zapraiser = draftEvent.zapraiserAmount() - wantsZapraiser = zapraiser != null - zapRaiserAmount.value = null - if (zapraiser != null) { - zapRaiserAmount.value = zapraiser - } - - eTags = - draftEvent.tags.filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) != "fork" }.mapNotNull { - val note = LocalCache.checkGetOrCreateNote(it[1]) - note - } - - pTags = - draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.map { - LocalCache.getOrCreateUser(it[1]) - } - - draftEvent.tags.filter { it.size > 3 && (it[0] == "e" || it[0] == "a") && it.get(3) == "fork" }.forEach { - val note = LocalCache.checkGetOrCreateNote(it[1]) - forkedFromNote = note - } - - originalNote = - draftEvent - .tags - .filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) == "reply" } - .map { - LocalCache.checkGetOrCreateNote(it[1]) - }.firstOrNull() - - if (originalNote == null) { - originalNote = - draftEvent - .tags - .filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) == "root" } - .map { - LocalCache.checkGetOrCreateNote(it[1]) - }.firstOrNull() - } - - canUsePoll = originalNote == null - - if (forwardZapTo.value.items.isNotEmpty()) { - wantsForwardZapTo = true - } - - val polls = draftEvent.tags.filter { it.size > 1 && it[0] == "poll_option" } - wantsPoll = polls.isNotEmpty() - - polls.forEach { - pollOptions[it[1].toInt()] = it[2] - } - - val minMax = draftEvent.tags.filter { it.size > 1 && (it[0] == "value_minimum" || it[0] == "value_maximum") } - minMax.forEach { - if (it[0] == "value_maximum") { - valueMaximum = it[1].toLong() - } else if (it[0] == "value_minimum") { - valueMinimum = it[1].toLong() - } - } - - wantsProduct = draftEvent.kind == 30402 - - title = - TextFieldValue( - draftEvent - .tags - .filter { it.size > 1 && it[0] == "title" } - .map { it[1] } - ?.firstOrNull() ?: "", - ) - price = - TextFieldValue( - draftEvent - .tags - .filter { it.size > 1 && it[0] == "price" } - .map { it[1] } - ?.firstOrNull() ?: "", - ) - category = - TextFieldValue( - draftEvent - .tags - .filter { it.size > 1 && it[0] == "t" } - .map { it[1] } - ?.firstOrNull() ?: "", - ) - locationText = - TextFieldValue( - draftEvent - .tags - .filter { it.size > 1 && it[0] == "location" } - .map { it[1] } - ?.firstOrNull() ?: "", - ) - condition = ConditionTag.CONDITION.entries.firstOrNull { - it.value == - draftEvent - .tags - .filter { it.size > 1 && it[0] == "condition" } - .map { it[1] } - .firstOrNull() - } ?: ConditionTag.CONDITION.USED_LIKE_NEW - - message = TextFieldValue(draftEvent.content) - - iMetaAttachments.addAll(draftEvent.imetas()) - - urlPreviews.update(message) - } - - fun sendPost(relayList: List) { - viewModelScope.launch(Dispatchers.IO) { - innerSendPost(relayList, null) - accountViewModel?.deleteDraft(draftTag) - cancel() - } - } - - fun sendDraft(relayList: List) { - viewModelScope.launch(Dispatchers.IO) { - sendDraftSync(relayList) - } - } - - suspend fun sendDraftSync(relayList: List) { - if (message.text.isBlank()) { - account?.deleteDraft(draftTag) - } else { - innerSendPost(relayList, draftTag) - } - } - - private suspend fun innerSendPost( - relayList: List, - localDraft: String?, - ) = withContext(Dispatchers.IO) { - if (accountViewModel == null) { - cancel() - return@withContext - } - - val tagger = NewMessageTagger(message.text, pTags, eTags, originalNote?.channelHex(), accountViewModel!!) - tagger.run() - - val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null - - val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() - val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null - - nip95attachments.forEach { - if (eTags?.contains(LocalCache.getNoteIfExists(it.second.id)) == true) { - account?.sendNip95(it.first, it.second, relayList) - } - } - - val emojis = findEmoji(tagger.message, account?.myEmojis?.value) - val urls = findURLs(tagger.message) - val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) - - val replyingTo = originalNote - val contentWarningReason = if (wantsToMarkAsSensitive) "" else null - - val channel = originalNote?.channelHex()?.let { LocalCache.getChannelIfExists(it) } - - if (replyingTo?.event is CommentEvent || replyingTo?.event is RootScope) { - val eventHint = replyingTo.toEventHint() ?: return@withContext - - val template = - CommentEvent.replyBuilder( - msg = tagger.message, - replyingTo = eventHint, - ) { - tagger.pTags?.let { notify(it.map { it.toPTag() }) } - - hashtags(findHashtags(tagger.message)) - references(findURLs(tagger.message)) - quotes(findNostrUris(tagger.message)) - - geoHash?.let { geohash(it) } - localZapRaiserAmount?.let { zapraiser(it) } - zapReceiver?.let { zapSplits(it) } - contentWarningReason?.let { contentWarning(it) } - - emojis(emojis) - imetas(usedAttachments) - } - - account?.signAndSend(localDraft, template, relayList, setOf(replyingTo)) - } else if (wantsExclusiveGeoPost && geoHash != null && originalNote == null) { - val template = - CommentEvent.replyExternalIdentity( - msg = tagger.message, - extId = GeohashId(geoHash), - ) { - tagger.pTags?.let { notify(it.map { it.toPTag() }) } - - hashtags(findHashtags(tagger.message)) - references(findURLs(tagger.message)) - quotes(findNostrUris(tagger.message)) - - localZapRaiserAmount?.let { zapraiser(it) } - zapReceiver?.let { zapSplits(it) } - contentWarningReason?.let { contentWarning(it) } - - emojis(emojis) - imetas(usedAttachments) - } - - account?.signAndSend(localDraft, template, relayList, emptyList()) - } else if (channel != null) { - if (channel is PublicChatChannel) { - val replyingToEvent = originalNote?.toEventHint() - val channelEvent = channel.event - val channelRelays = channel.relays() - - val template = - if (replyingToEvent != null) { - ChannelMessageEvent.reply(tagger.message, replyingToEvent) { - tagger.pTags?.let { notify(it.map { it.toPTag() }) } - - hashtags(findHashtags(tagger.message)) - references(findURLs(tagger.message)) - quotes(findNostrUris(tagger.message)) - - geoHash?.let { geohash(it) } - localZapRaiserAmount?.let { zapraiser(it) } - zapReceiver?.let { zapSplits(it) } - contentWarningReason?.let { contentWarning(it) } - - emojis(emojis) - imetas(usedAttachments) - } - } else if (channelEvent != null) { - val hint = EventHintBundle(channelEvent, channelRelays.firstOrNull()) - ChannelMessageEvent.message(tagger.message, hint) { - tagger.pTags?.let { notify(it.map { it.toPTag() }) } - - hashtags(findHashtags(tagger.message)) - references(findURLs(tagger.message)) - quotes(findNostrUris(tagger.message)) - - geoHash?.let { geohash(it) } - localZapRaiserAmount?.let { zapraiser(it) } - zapReceiver?.let { zapSplits(it) } - contentWarningReason?.let { contentWarning(it) } - - emojis(emojis) - imetas(usedAttachments) - } - } else { - ChannelMessageEvent.message(tagger.message, ETag(channel.idHex, channelRelays.firstOrNull())) { - tagger.pTags?.let { notify(it.map { it.toPTag() }) } - - hashtags(findHashtags(tagger.message)) - references(findURLs(tagger.message)) - quotes(findNostrUris(tagger.message)) - - geoHash?.let { geohash(it) } - localZapRaiserAmount?.let { zapraiser(it) } - zapReceiver?.let { zapSplits(it) } - contentWarningReason?.let { contentWarning(it) } - - emojis(emojis) - imetas(usedAttachments) - } - } - - val broadcast = tagger.directMentionsNotes + (tagger.eTags ?: emptyList()) - - account?.signAndSendWithList(draftTag, template, channelRelays, broadcast) - } else if (channel is LiveActivitiesChannel) { - val replyingToEvent = originalNote?.toEventHint() - val activity = channel.info - val channelRelays = channel.relays() - - val template = - if (replyingToEvent != null) { - LiveActivitiesChatMessageEvent.reply(tagger.message, replyingToEvent) { - tagger.pTags?.let { notify(it.map { it.toPTag() }) } - - hashtags(findHashtags(tagger.message)) - references(findURLs(tagger.message)) - quotes(findNostrUris(tagger.message)) - - geoHash?.let { geohash(it) } - localZapRaiserAmount?.let { zapraiser(it) } - zapReceiver?.let { zapSplits(it) } - contentWarningReason?.let { contentWarning(it) } - - emojis(emojis) - imetas(usedAttachments) - } - } else if (activity != null) { - val hint = EventHintBundle(activity, channelRelays.firstOrNull() ?: replyingToEvent?.relay) - - LiveActivitiesChatMessageEvent.message(tagger.message, hint) { - tagger.pTags?.let { notify(it.map { it.toPTag() }) } - - hashtags(findHashtags(tagger.message)) - references(findURLs(tagger.message)) - quotes(findNostrUris(tagger.message)) - - geoHash?.let { geohash(it) } - localZapRaiserAmount?.let { zapraiser(it) } - zapReceiver?.let { zapSplits(it) } - contentWarningReason?.let { contentWarning(it) } - - emojis(emojis) - imetas(usedAttachments) - } - } else { - LiveActivitiesChatMessageEvent.message(tagger.message, channel.toATag()) { - tagger.pTags?.let { notify(it.map { it.toPTag() }) } - - hashtags(findHashtags(tagger.message)) - references(findURLs(tagger.message)) - quotes(findNostrUris(tagger.message)) - - geoHash?.let { geohash(it) } - localZapRaiserAmount?.let { zapraiser(it) } - zapReceiver?.let { zapSplits(it) } - contentWarningReason?.let { contentWarning(it) } - - emojis(emojis) - imetas(usedAttachments) - } - } - - val broadcast = tagger.directMentionsNotes + (tagger.eTags ?: emptyList()) - - account?.signAndSendWithList(draftTag, template, channelRelays, broadcast) - } - } else if (originalNote?.event is GitIssueEvent) { - val originalNoteHint = originalNote?.toEventHint() ?: return@withContext - - val template = - GitReplyEvent.replyIssue( - tagger.message, - originalNoteHint, - ) { - tagger.pTags?.let { notify(it.map { it.toPTag() }) } - - hashtags(findHashtags(tagger.message)) - references(findURLs(tagger.message)) - quotes(findNostrUris(tagger.message)) - - geoHash?.let { geohash(it) } - localZapRaiserAmount?.let { zapraiser(it) } - zapReceiver?.let { zapSplits(it) } - contentWarningReason?.let { contentWarning(it) } - - emojis(emojis) - imetas(usedAttachments) - } - - val broadcast = tagger.directMentionsNotes + (tagger.eTags ?: emptyList()) - - account?.signAndSend(localDraft, template, relayList, broadcast) - } else if (originalNote?.event is TorrentCommentEvent) { - val replyToEvent = originalNote?.event as TorrentCommentEvent - - val rootETag = replyToEvent.torrent() - val rootNote = rootETag?.eventId?.let { LocalCache.getNoteIfExists(it) } - val rootNoteEvent = rootNote?.event - - // only uses the root node if the event is loaded. - val root = - if (rootNoteEvent != null) { - rootNote // refreshes author and relay hint to what we have. - } else { - rootETag?.let { LocalCache.getOrCreateNote(it) } // keeps what came in. - ?: originalNote?.replyTo?.firstOrNull { it.event != null && it.replyTo?.isEmpty() == true } // if it has loaded events with zero replies in the reply list - ?: originalNote?.replyTo?.firstOrNull() // old rules, first item is root. - ?: originalNote - } - - if (root != null) { - val replyToSet = - if (forkedFromNote != null) { - (listOfNotNull(forkedFromNote) + (tagger.eTags ?: emptyList())).ifEmpty { null } - } else { - tagger.eTags - } - - val sortedAndMarked = - eTags?.map { it.toETag() }?.positionalMarkedTags( - root = root.toETag(), - replyingTo = replyingTo?.toETag(), - forkedFrom = forkedFromNote?.toETag(), - ) - - val template = - TorrentCommentEvent.build(tagger.message) { - sortedAndMarked?.let { eTags(sortedAndMarked) } - - pTags(tagger.directMentionsUsers.map { it.toPTag() }) - - hashtags(findHashtags(tagger.message)) - references(findURLs(tagger.message)) - quotes(findNostrUris(tagger.message)) - - geoHash?.let { geohash(it) } - localZapRaiserAmount?.let { zapraiser(it) } - zapReceiver?.let { zapSplits(it) } - contentWarningReason?.let { contentWarning(it) } - - emojis(emojis) - imetas(usedAttachments) - } - - val broadcast = tagger.directMentionsNotes + (replyToSet ?: emptySet()) - - account?.sendTorrentComment(localDraft, template, broadcast, relayList) - } - } else if (originalNote?.event is TorrentEvent) { - val replyToSet = - if (forkedFromNote != null) { - (listOfNotNull(forkedFromNote) + (tagger.eTags ?: emptyList())).ifEmpty { null } - } else { - tagger.eTags - } - - val sortedAndMarked = - eTags?.map { it.toETag() }?.positionalMarkedTags( - root = originalNote?.toETag(), - replyingTo = null, - forkedFrom = forkedFromNote?.toETag(), - ) - - val template = - TorrentCommentEvent.build(tagger.message) { - sortedAndMarked?.let { eTags(sortedAndMarked) } - - hashtags(findHashtags(tagger.message)) - references(findURLs(tagger.message)) - quotes(findNostrUris(tagger.message)) - - geoHash?.let { geohash(it) } - localZapRaiserAmount?.let { zapraiser(it) } - zapReceiver?.let { zapSplits(it) } - contentWarningReason?.let { contentWarning(it) } - - emojis(emojis) - imetas(usedAttachments) - } - - val broadcast = tagger.directMentionsNotes + (replyToSet ?: emptySet()) - - account?.sendTorrentComment(localDraft, template, broadcast, relayList) - } else { - if (wantsPoll) { - val options = pollOptions.map { PollOptionTag(it.key, it.value) } - - if (options.isEmpty()) return@withContext - - val quotes = findNostrUris(tagger.message) - - val template = - PollNoteEvent.build(tagger.message, options) { - valueMinimum?.let { minAmount(it) } - valueMaximum?.let { maxAmount(it) } - closedAt?.let { closedAt(it) } - consensusThreshold?.let { consensusThreshold(it / 100.0) } - - pTags(tagger.directMentionsUsers.map { it.toPTag() }) - quotes(quotes) - hashtags(findHashtags(tagger.message)) - - geoHash?.let { geohash(it) } - localZapRaiserAmount?.let { zapraiser(it) } - zapReceiver?.let { zapSplits(it) } - contentWarningReason?.let { contentWarning(it) } - - emojis(emojis) - imetas(usedAttachments) - } - - account?.signAndSend(localDraft, template, relayList, quotes) - } else if (wantsProduct) { - val images = - urls.mapNotNull { - val removedParamsFromUrl = - if (it.contains("?")) { - it.split("?")[0].lowercase() - } else if (it.contains("#")) { - it.split("#")[0].lowercase() - } else { - it - } - - if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { - it - } else { - null - } - } - - val quotes = findNostrUris(tagger.message) - - val template = - ClassifiedsEvent.build( - title.text, - PriceTag(price.text, "SATS", null), - tagger.message, - locationText.text.ifBlank { null }, - condition, - images, - ) { - hashtags(listOfNotNull(category.text.ifBlank { null }) + findHashtags(tagger.message)) - quotes(quotes) - - geoHash?.let { geohash(it) } - localZapRaiserAmount?.let { zapraiser(it) } - zapReceiver?.let { zapSplits(it) } - contentWarningReason?.let { contentWarning(it) } - - emojis(emojis) - imetas(usedAttachments) - } - - account?.signAndSend(localDraft, template, relayList, quotes) - } else { - val replyToSet = - if (forkedFromNote != null) { - (listOfNotNull(forkedFromNote) + (tagger.eTags ?: emptyList())).ifEmpty { null } - } else { - tagger.eTags - } - - val template = - TextNoteEvent.build( - note = tagger.message, - replyingTo = originalNote?.toEventHint(), - forkingFrom = forkedFromNote?.toEventHint(), - ) { - tagger.pTags?.let { notify(it.map { it.toPTag() }) } - - hashtags(findHashtags(tagger.message)) - references(findURLs(tagger.message)) - quotes(findNostrUris(tagger.message)) - - geoHash?.let { geohash(it) } - localZapRaiserAmount?.let { zapraiser(it) } - zapReceiver?.let { zapSplits(it) } - contentWarningReason?.let { contentWarning(it) } - - emojis(emojis) - imetas(usedAttachments) - } - - val broadcast = tagger.directMentionsNotes + (replyToSet ?: emptySet()) - - account?.signAndSend(localDraft, template, relayList, broadcast) - } - } - } - - fun findEmoji( - message: String, - myEmojiSet: List?, - ): List { - if (myEmojiSet == null) return emptyList() - return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> - myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.url.url) } - } - } - - fun upload( - alt: String?, - contentWarningReason: String?, - mediaQuality: Int, - isPrivate: Boolean = false, - server: ServerName, - onError: (title: String, message: String) -> Unit, - context: Context, - ) { - viewModelScope.launch(Dispatchers.Default) { - val myAccount = account ?: return@launch - - val myMultiOrchestrator = multiOrchestrator ?: return@launch - - isUploadingImage = true - - val results = - myMultiOrchestrator.upload( - viewModelScope, - alt, - contentWarningReason, - MediaCompressor.intToCompressorQuality(mediaQuality), - server, - myAccount, - context, - ) - - if (results.allGood) { - results.successful.forEach { - if (it.result is UploadOrchestrator.OrchestratorResult.NIP95Result) { - account?.createNip95(it.result.bytes, headerInfo = it.result.fileHeader, alt, contentWarningReason) { nip95 -> - nip95attachments = nip95attachments + nip95 - val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } - - note?.let { - message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) - urlPreviews.update(message) - } - } - } else if (it.result is UploadOrchestrator.OrchestratorResult.ServerResult) { - val iMeta = - IMetaTagBuilder(it.result.url) - .apply { - hash(it.result.fileHeader.hash) - size(it.result.fileHeader.size) - it.result.fileHeader.mimeType - ?.let { mimeType(it) } - it.result.fileHeader.dim - ?.let { dims(it) } - it.result.fileHeader.blurHash - ?.let { blurhash(it.blurhash) } - it.result.magnet?.let { magnet(it) } - it.result.uploadedHash?.let { originalHash(it) } - - alt?.let { alt(it) } - contentWarningReason?.let { sensitiveContent(contentWarningReason) } - }.build() - - iMetaAttachments.replace(iMeta.url, iMeta) - - message = message.insertUrlAtCursor(it.result.url) - urlPreviews.update(message) - } - } - - multiOrchestrator = null - } else { - val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct() - - onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) - } - - isUploadingImage = false - } - } - - open fun cancel() { - message = TextFieldValue("") - - forkedFromNote = null - - multiOrchestrator = null - isUploadingImage = false - pTags = null - - wantsPoll = false - zapRecipients = mutableStateListOf() - pollOptions = newStateMapPollOptions() - valueMaximum = null - valueMinimum = null - consensusThreshold = null - closedAt = null - - wantsInvoice = false - wantsZapraiser = false - zapRaiserAmount.value = null - - wantsProduct = false - condition = ConditionTag.CONDITION.USED_LIKE_NEW - locationText = TextFieldValue("") - title = TextFieldValue("") - category = TextFieldValue("") - price = TextFieldValue("") - - wantsForwardZapTo = false - wantsToMarkAsSensitive = false - wantsToAddGeoHash = false - wantsExclusiveGeoPost = false - wantsSecretEmoji = false - - forwardZapTo.value = SplitBuilder() - forwardZapToEditting.value = TextFieldValue("") - - urlPreviews.reset() - - userSuggestions?.reset() - userSuggestionsMainMessage = null - - iMetaAttachments.reset() - - emojiSuggestions?.reset() - - draftTag = UUID.randomUUID().toString() - - NostrSearchEventOrUserDataSource.clear() - } - - fun deleteDraft() { - viewModelScope.launch(Dispatchers.IO) { - accountViewModel?.deleteDraft(draftTag) - } - } - - fun deleteMediaToUpload(selected: SelectedMediaProcessing) { - this.multiOrchestrator?.remove(selected) - } - - open fun removeFromReplyList(userToRemove: User) { - pTags = pTags?.filter { it != userToRemove } - } - - private fun saveDraft() { - draftTextChanges.trySend("") - } - - open fun addToMessage(it: String) { - updateMessage(TextFieldValue(message.text + " " + it)) - } - - override fun updateMessage(it: TextFieldValue) { - message = it - urlPreviews.update(message) - - if (message.selection.collapsed) { - val lastWord = message.currentWord() - - userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE - userSuggestions?.processCurrentWord(lastWord) - - emojiSuggestions?.processCurrentWord(lastWord) - } - - saveDraft() - } - - override fun updateZapForwardTo(newZapForwardTo: TextFieldValue) { - forwardZapToEditting.value = newZapForwardTo - if (newZapForwardTo.selection.collapsed) { - val lastWord = newZapForwardTo.text - userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS - userSuggestions?.processCurrentWord(lastWord) - } - } - - open fun autocompleteWithUser(item: User) { - userSuggestions?.let { userSuggestions -> - if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { - val lastWord = message.currentWord() - message = userSuggestions.replaceCurrentWord(message, lastWord, item) - urlPreviews.update(message) - } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { - forwardZapTo.value.addItem(item) - forwardZapToEditting.value = TextFieldValue("") - } - - userSuggestionsMainMessage = null - userSuggestions.reset() - } - - saveDraft() - } - - open fun autocompleteWithEmoji(item: Account.EmojiMedia) { - val wordToInsert = ":${item.code}:" - - message = message.replaceCurrentWord(wordToInsert) - urlPreviews.update(message) - - emojiSuggestions?.reset() - - saveDraft() - } - - open fun autocompleteWithEmojiUrl(item: Account.EmojiMedia) { - val wordToInsert = item.url.url + " " - - viewModelScope.launch(Dispatchers.IO) { - iMetaAttachments.downloadAndPrepare( - item.url.url, - { Amethyst.instance.okHttpClients.getHttpClient(accountViewModel?.account?.shouldUseTorForImageDownload() ?: false) }, - ) - } - - message = message.replaceCurrentWord(wordToInsert) - urlPreviews.update(message) - - emojiSuggestions?.reset() - - saveDraft() - } - - private fun newStateMapPollOptions(): SnapshotStateMap = mutableStateMapOf(Pair(0, ""), Pair(1, "")) - - fun canPost(): Boolean = - message.text.isNotBlank() && - !isUploadingImage && - !wantsInvoice && - (!wantsZapraiser || zapRaiserAmount.value != null) && - ( - !wantsPoll || - ( - pollOptions.values.all { it.isNotEmpty() } && - isValidvalueMinimum.value && - isValidvalueMaximum.value - ) - ) && - ( - !wantsProduct || - ( - !title.text.isNullOrBlank() && - !price.text.isNullOrBlank() && - !category.text.isNullOrBlank() - ) - ) && - multiOrchestrator == null - - fun insertAtCursor(newElement: String) { - message = message.insertUrlAtCursor(newElement) - } - - fun selectImage(uris: ImmutableList) { - multiOrchestrator = MultiOrchestrator(uris) - } - - override fun locationFlow(): StateFlow { - if (location == null) { - location = locationManager().geohashStateFlow - } - - return location!! - } - - override fun onCleared() { - super.onCleared() - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") - } - - fun updateMinZapAmountForPoll(textMin: String) { - valueMinimum = textMin.toLongOrNull()?.takeIf { it > 0 } - checkMinMax() - saveDraft() - } - - fun updateMaxZapAmountForPoll(textMax: String) { - valueMaximum = textMax.toLongOrNull()?.takeIf { it > 0 } - checkMinMax() - saveDraft() - } - - fun checkMinMax() { - if ((valueMinimum ?: 0) > (valueMaximum ?: Long.MAX_VALUE)) { - isValidvalueMinimum.value = false - isValidvalueMaximum.value = false - } else { - isValidvalueMinimum.value = true - isValidvalueMaximum.value = true - } - } - - override fun updateZapPercentage( - index: Int, - sliderValue: Float, - ) { - forwardZapTo.value.updatePercentage(index, sliderValue) - } - - override fun updateZapFromText() { - viewModelScope.launch(Dispatchers.Default) { - val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), null, accountViewModel!!) - tagger.run() - tagger.pTags?.forEach { taggedUser -> - if (!forwardZapTo.value.items.any { it.key == taggedUser }) { - forwardZapTo.value.addItem(taggedUser) - } - } - } - } - - override fun updateZapRaiserAmount(newAmount: Long?) { - zapRaiserAmount.value = newAmount - saveDraft() - } - - fun removePollOption(optionIndex: Int) { - pollOptions.removeOrdered(optionIndex) - saveDraft() - } - - private fun MutableMap.removeOrdered(index: Int) { - val keyList = keys - val elementList = values.toMutableList() - run stop@{ - for (i in index until elementList.size) { - val nextIndex = i + 1 - if (nextIndex == elementList.size) return@stop - elementList[i] = elementList[nextIndex].also { elementList[nextIndex] = "null" } - } - } - elementList.removeAt(elementList.size - 1) - val newEntries = keyList.zip(elementList) { key, content -> Pair(key, content) } - this.clear() - this.putAll(newEntries) - } - - fun updatePollOption( - optionIndex: Int, - text: String, - ) { - pollOptions[optionIndex] = text - saveDraft() - } - - fun toggleMarkAsSensitive() { - wantsToMarkAsSensitive = !wantsToMarkAsSensitive - saveDraft() - } - - fun updateTitle(it: TextFieldValue) { - title = it - saveDraft() - } - - fun updatePrice(it: TextFieldValue) { - runCatching { - if (it.text.isEmpty()) { - price = TextFieldValue("") - } else if (it.text.toLongOrNull() != null) { - price = it - } - } - saveDraft() - } - - fun updateCondition(newCondition: ConditionTag.CONDITION) { - condition = newCondition - saveDraft() - } - - fun updateCategory(value: TextFieldValue) { - category = value - saveDraft() - } - - fun updateLocation(it: TextFieldValue) { - locationText = it - saveDraft() - } - - override fun locationManager(): LocationState = Amethyst.instance.locationManager -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt index 75fc40dcc7..6d8b8c1f58 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt @@ -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,9 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.actions -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth @@ -37,28 +35,20 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.uploads.SelectSingleFromGallery -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.MinHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText @OptIn(ExperimentalMaterial3Api::class) @@ -68,62 +58,25 @@ fun NewUserMetadataScreen( accountViewModel: AccountViewModel, ) { val postViewModel: NewUserMetadataViewModel = viewModel() + postViewModel.init(accountViewModel) val context = LocalContext.current - LaunchedEffect(Unit) { - postViewModel.load(accountViewModel.account) - } - - DisposableEffect(Unit) { - onDispose { - postViewModel.clear() - } + LaunchedEffect(accountViewModel) { + postViewModel.load() } Scaffold( topBar = { - TopAppBar( - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer(modifier = MinHorzSpacer) - - Text( - text = stringRes(R.string.profile), - modifier = Modifier.weight(1f), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLarge, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - ) - - SaveButton( - onPost = { - postViewModel.create() - nav.popBack() - }, - true, - ) - } + SavingTopBar( + titleRes = R.string.profile, + onCancel = { + postViewModel.clear() + nav.popBack() }, - navigationIcon = { - Row { - Spacer(modifier = StdHorzSpacer) - CloseButton( - onPress = { - postViewModel.clear() - nav.popBack() - }, - ) - } + onPost = { + postViewModel.create() + nav.popBack() }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), ) }, ) { pad -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index f0e7b2ecfc..5d0bc32227 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -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,8 +25,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import coil3.util.CoilUtils.result import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account @@ -36,16 +34,17 @@ import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip39ExtIdentities.GitHubIdentity import com.vitorpamplona.quartz.nip39ExtIdentities.MastodonIdentity import com.vitorpamplona.quartz.nip39ExtIdentities.TwitterIdentity import com.vitorpamplona.quartz.nip39ExtIdentities.identityClaims -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlin.coroutines.cancellation.CancellationException class NewUserMetadataViewModel : ViewModel() { + private lateinit var accountViewModel: AccountViewModel private lateinit var account: Account // val userName = mutableStateOf("") @@ -68,9 +67,12 @@ class NewUserMetadataViewModel : ViewModel() { var isUploadingImageForPicture by mutableStateOf(false) var isUploadingImageForBanner by mutableStateOf(false) - fun load(account: Account) { - this.account = account + fun init(accountViewModel: AccountViewModel) { + this.accountViewModel = accountViewModel + this.account = accountViewModel.account + } + fun load() { account.userProfile().let { // userName.value = it.bestUsername() ?: "" displayName.value = it.info?.bestName() ?: "" @@ -100,21 +102,25 @@ class NewUserMetadataViewModel : ViewModel() { fun create() { // Tries to not delete any existing attribute that we do not work with. - viewModelScope.launch(Dispatchers.IO) { - account.sendNewUserMetadata( - name = displayName.value, - picture = picture.value, - banner = banner.value, - website = website.value, - pronouns = pronouns.value, - about = about.value, - nip05 = nip05.value, - lnAddress = lnAddress.value, - lnURL = lnURL.value, - twitter = twitter.value, - mastodon = mastodon.value, - github = github.value, - ) + accountViewModel.runIOCatching { + val metadata = + account.userMetadata.sendNewUserMetadata( + name = displayName.value, + picture = picture.value, + banner = banner.value, + website = website.value, + pronouns = pronouns.value, + about = about.value, + nip05 = nip05.value, + lnAddress = lnAddress.value, + lnURL = lnURL.value, + twitter = twitter.value, + mastodon = mastodon.value, + github = github.value, + ) + + account.sendLiterallyEverywhere(metadata) + clear() } } @@ -139,7 +145,7 @@ class NewUserMetadataViewModel : ViewModel() { context: Context, onError: (String, String) -> Unit, ) { - viewModelScope.launch(Dispatchers.IO) { + accountViewModel.runIOCatching { upload( uri, context, @@ -155,7 +161,7 @@ class NewUserMetadataViewModel : ViewModel() { context: Context, onError: (String, String) -> Unit, ) { - viewModelScope.launch(Dispatchers.IO) { + accountViewModel.runIOCatching { upload( uri, context, @@ -187,7 +193,7 @@ class NewUserMetadataViewModel : ViewModel() { alt = null, sensitiveContent = null, serverBaseUrl = account.settings.defaultFileServer.baseUrl, - okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(it)) }, + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(it)) }, onProgress = {}, httpAuth = account::createHTTPAuthorization, context = context, @@ -200,7 +206,7 @@ class NewUserMetadataViewModel : ViewModel() { alt = null, sensitiveContent = null, serverBaseUrl = account.settings.defaultFileServer.baseUrl, - okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(it)) }, + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(it)) }, httpAuth = account::createBlossomUploadAuth, context = context, ) @@ -213,6 +219,9 @@ class NewUserMetadataViewModel : ViewModel() { onUploading(false) onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.server_did_not_provide_a_url_after_uploading)) } + } catch (_: SignerExceptions.ReadOnlyException) { + onUploading(false) + onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.login_with_a_private_key_to_be_able_to_upload)) } catch (e: Exception) { if (e is CancellationException) throw e onUploading(false) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelaySelectionDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelaySelectionDialog.kt deleted file mode 100644 index 56191f4477..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelaySelectionDialog.kt +++ /dev/null @@ -1,262 +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.ui.actions - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.service.Nip11Retriever -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationDialog -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.FeedPadding -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache -import com.vitorpamplona.ammolite.relays.RelaySetupInfo -import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList - -data class RelayList( - val relay: RelaySetupInfo, - val relayInfo: RelayBriefInfoCache.RelayBriefInfo, - val isSelected: Boolean, -) - -data class RelayInfoDialog( - val relayBriefInfo: RelayBriefInfoCache.RelayBriefInfo, - val relayInfo: Nip11RelayInformation, -) - -@Composable -fun RelaySelectionDialog( - preSelectedList: ImmutableList, - onClose: () -> Unit, - onPost: (list: ImmutableList) -> Unit, - accountViewModel: AccountViewModel, - nav: INav, -) { - val context = LocalContext.current - - var relays by remember { - mutableStateOf( - accountViewModel.account.connectToRelays.value.map { - RelayList( - relay = it, - relayInfo = RelayBriefInfoCache.RelayBriefInfo(it.url), - isSelected = preSelectedList.any { relay -> it.url == relay.url }, - ) - }, - ) - } - - val hasSelectedRelay by remember { derivedStateOf { relays.any { it.isSelected } } } - - var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) } - - relayInfo?.let { - RelayInformationDialog( - onClose = { relayInfo = null }, - relayInfo = it.relayInfo, - relayBriefInfo = it.relayBriefInfo, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - var selected by remember { mutableStateOf(true) } - - Dialog( - onDismissRequest = { onClose() }, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - dismissOnClickOutside = false, - decorFitsSystemWindows = false, - ), - ) { - Surface( - modifier = Modifier.fillMaxWidth().fillMaxHeight(), - ) { - Column( - modifier = - Modifier.fillMaxWidth().fillMaxHeight().padding(start = 10.dp, end = 10.dp, top = 10.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - CloseButton( - onPress = { onClose() }, - ) - - SaveButton( - onPost = { - val selectedRelays = relays.filter { it.isSelected } - onPost(selectedRelays.map { it.relay }.toImmutableList()) - onClose() - }, - isActive = hasSelectedRelay, - ) - } - - RelaySwitch( - text = stringRes(context, R.string.select_deselect_all), - checked = selected, - onClick = { - selected = !selected - relays = relays.mapIndexed { _, item -> item.copy(isSelected = selected) } - }, - ) - - LazyColumn( - contentPadding = FeedPadding, - ) { - itemsIndexed( - relays, - key = { _, item -> item.relay.url }, - ) { index, item -> - RelaySwitch( - text = item.relayInfo.displayUrl, - checked = item.isSelected, - onClick = { - relays = - relays.mapIndexed { j, item -> - if (index == j) { - item.copy(isSelected = !item.isSelected) - } else { - item - } - } - }, - onLongPress = { - accountViewModel.retrieveRelayDocument( - item.relay.url, - onInfo = { - relayInfo = - RelayInfoDialog( - RelayBriefInfoCache.RelayBriefInfo( - item.relay.url, - ), - it, - ) - }, - onError = { url, errorCode, exceptionMessage -> - val msg = - when (errorCode) { - Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - } - - accountViewModel.toastManager.toast( - stringRes(context, R.string.unable_to_download_relay_document), - msg, - ) - }, - ) - }, - ) - } - } - } - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun RelaySwitch( - text: String, - checked: Boolean, - onClick: () -> Unit, - onLongPress: () -> Unit = {}, -) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier.combinedClickable( - onClick = onClick, - onLongClick = onLongPress, - ), - ) { - Text( - modifier = Modifier.weight(1f), - text = text, - ) - Switch( - checked = checked, - onCheckedChange = { onClick() }, - ) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt index 9b6bb948e6..650be6f5fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt index 94812d35db..98887780e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt @@ -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,10 +24,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope @@ -35,138 +32,29 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategoryWithButton import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.DoubleVertPadding import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier +import com.vitorpamplona.amethyst.ui.theme.SettingsCategorySpacingModifier import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.grayText -@Composable -fun MediaServersListView( - onClose: () -> Unit, - accountViewModel: AccountViewModel, - nav: INav, -) { - val nip96ServersViewModel: NIP96ServersViewModel = viewModel() - val blossomServersViewModel: BlossomServersViewModel = viewModel() - - LaunchedEffect(key1 = Unit) { - nip96ServersViewModel.load(accountViewModel.account) - blossomServersViewModel.load(accountViewModel.account) - } - - Dialog( - onDismissRequest = onClose, - properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false), - ) { - SetDialogToEdgeToEdge() - DialogContent(nip96ServersViewModel, blossomServersViewModel, onClose) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun DialogContent( - nip96ServersViewModel: NIP96ServersViewModel, - blossomServersViewModel: BlossomServersViewModel, - onClose: () -> Unit, -) { - Scaffold( - topBar = { - TopAppBar( - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceAround, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringRes(id = R.string.media_servers), - style = MaterialTheme.typography.titleLarge, - ) - } - }, - navigationIcon = { - CloseButton( - onPress = { - nip96ServersViewModel.refresh() - blossomServersViewModel.refresh() - onClose() - }, - ) - }, - actions = { - SaveButton( - onPost = { - nip96ServersViewModel.saveFileServers() - blossomServersViewModel.saveFileServers() - onClose() - }, - isActive = true, - ) - }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - ) - }, - ) { padding -> - Column( - modifier = - Modifier - .fillMaxSize() - .padding( - start = 16.dp, - top = padding.calculateTopPadding(), - end = 16.dp, - bottom = padding.calculateBottomPadding(), - ).consumeWindowInsets(padding) - .imePadding(), - verticalArrangement = Arrangement.spacedBy(10.dp, alignment = Alignment.Top), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - stringRes(id = R.string.set_preferred_media_servers), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.grayText, - ) - - AllMediaBody(nip96ServersViewModel, blossomServersViewModel) - } - } -} - @Composable fun AllMediaBody( nip96ServersViewModel: NIP96ServersViewModel, @@ -180,31 +68,11 @@ fun AllMediaBody( horizontalAlignment = Alignment.CenterHorizontally, contentPadding = FeedPadding, ) { - item { - SettingsCategory( - stringRes(R.string.media_servers_nip96_section), - stringRes(R.string.media_servers_nip96_explainer), - Modifier.padding(bottom = 8.dp), - ) - } - - renderMediaServerList( - mediaServersState = nip96ServersState, - keyType = "nip96", - editLabel = R.string.add_a_nip96_server, - emptyLabel = R.string.no_nip96_server_message, - onAddServer = { server -> - nip96ServersViewModel.addServer(server) - }, - onDeleteServer = { - nip96ServersViewModel.removeServer(serverUrl = it) - }, - ) - item { SettingsCategory( stringRes(R.string.media_servers_blossom_section), stringRes(R.string.media_servers_blossom_explainer), + SettingsCategoryFirstModifier, ) } @@ -221,27 +89,48 @@ fun AllMediaBody( }, ) + item { + SettingsCategory( + stringRes(R.string.media_servers_nip96_section), + stringRes(R.string.media_servers_nip96_explainer), + SettingsCategorySpacingModifier, + ) + } + + renderMediaServerList( + mediaServersState = nip96ServersState, + keyType = "nip96", + editLabel = R.string.add_a_nip96_server, + emptyLabel = R.string.no_nip96_server_message, + onAddServer = { server -> + nip96ServersViewModel.addServer(server) + }, + onDeleteServer = { + nip96ServersViewModel.removeServer(serverUrl = it) + }, + ) + DEFAULT_MEDIA_SERVERS.let { item { SettingsCategoryWithButton( title = stringRes(id = R.string.built_in_media_servers_title), description = stringRes(id = R.string.built_in_servers_description), - action = { - OutlinedButton( - onClick = { - nip96ServersViewModel.addServerList( - it.mapNotNull { s -> if (s.type == ServerType.NIP96) s.baseUrl else null }, - ) + modifier = SettingsCategorySpacingModifier, + ) { + OutlinedButton( + onClick = { + nip96ServersViewModel.addServerList( + it.mapNotNull { s -> if (s.type == ServerType.NIP96) s.baseUrl else null }, + ) - blossomServersViewModel.addServerList( - it.mapNotNull { s -> if (s.type == ServerType.Blossom) s.baseUrl else null }, - ) - }, - ) { - Text(text = stringRes(id = R.string.use_default_servers)) - } - }, - ) + blossomServersViewModel.addServerList( + it.mapNotNull { s -> if (s.type == ServerType.Blossom) s.baseUrl else null }, + ) + }, + ) { + Text(text = stringRes(id = R.string.use_default_servers)) + } + } } itemsIndexed( it, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt new file mode 100644 index 0000000000..587fab25fa --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt @@ -0,0 +1,114 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions.mediaServers + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.grayText + +@Composable +fun AllMediaServersScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val nip96ServersViewModel: NIP96ServersViewModel = viewModel() + val blossomServersViewModel: BlossomServersViewModel = viewModel() + + LaunchedEffect(key1 = Unit) { + nip96ServersViewModel.load(accountViewModel.account) + blossomServersViewModel.load(accountViewModel.account) + } + + MediaServersScaffold(nip96ServersViewModel, blossomServersViewModel) { + nav.popBack() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MediaServersScaffold( + nip96ServersViewModel: NIP96ServersViewModel, + blossomServersViewModel: BlossomServersViewModel, + onClose: () -> Unit, +) { + Scaffold( + topBar = { + SavingTopBar( + titleRes = R.string.media_servers, + onCancel = { + nip96ServersViewModel.refresh() + blossomServersViewModel.refresh() + onClose() + }, + onPost = { + nip96ServersViewModel.saveFileServers() + blossomServersViewModel.saveFileServers() + onClose() + }, + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding( + start = 16.dp, + top = padding.calculateTopPadding(), + end = 16.dp, + bottom = padding.calculateBottomPadding(), + ).consumeWindowInsets(padding) + .imePadding(), + verticalArrangement = Arrangement.spacedBy(10.dp, alignment = Alignment.Top), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringRes(id = R.string.set_preferred_media_servers), + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 10.dp), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.grayText, + ) + + AllMediaBody(nip96ServersViewModel, blossomServersViewModel) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt index 4fd5e81f2d..6eab5aadb2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt @@ -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 @@ -127,5 +127,5 @@ class BlossomServersViewModel : ViewModel() { } } - private fun obtainFileServers(): List? = account.getBlossomServersList()?.servers() + private fun obtainFileServers(): List? = account.blossomServers.flow.value } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServerEditField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServerEditField.kt index 69ec4ef8b7..0d0e67c3ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServerEditField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServerEditField.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/NIP96ServersViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/NIP96ServersViewModel.kt index 3ef4aaf5ab..0d8c32b19a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/NIP96ServersViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/NIP96ServersViewModel.kt @@ -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 @@ -122,5 +122,5 @@ class NIP96ServersViewModel : ViewModel() { } } - private fun obtainFileServers(): List? = account.getFileServersList()?.servers() + private fun obtainFileServers(): List? = account.fileStorageServers.flow.value } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/ServerName.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/ServerName.kt index d22297eed9..a7698c60a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/ServerName.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/ServerName.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt new file mode 100644 index 0000000000..8f2eae9b8a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt @@ -0,0 +1,84 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions.uploads + +import android.Manifest +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.components.ClickAndHoldBoxComposable +import com.vitorpamplona.amethyst.ui.stringRes + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun RecordAudioBox( + modifier: Modifier, + onRecordTaken: (RecordingResult) -> Unit, + content: @Composable (Boolean) -> Unit, +) { + val mediaRecorder = remember { mutableStateOf(null) } + val context = LocalContext.current + + ClickAndHoldBoxComposable( + modifier = modifier, + onPress = { + val recordPermissionState = rememberPermissionState(Manifest.permission.RECORD_AUDIO) + val scope = rememberCoroutineScope() + LaunchedEffect(Unit) { + if (!recordPermissionState.status.isGranted) { + recordPermissionState.launchPermissionRequest() + } else { + mediaRecorder.value = VoiceMessageRecorder() + mediaRecorder.value?.start(context, scope) + } + } + }, + onRelease = { + val result = mediaRecorder.value?.stop() + if (result != null) { + onRecordTaken(result) + } else { + // less disruptive than error messages + Toast + .makeText( + context, + stringRes(context, R.string.record_a_message_description), + Toast.LENGTH_SHORT, + ).show() + } + }, + onCancel = { + mediaRecorder.value?.stop() + }, + content, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt index b11e1a7cc1..8d0bbaee3f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectedMediaProcessing.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectedMediaProcessing.kt index b9b22e1cf2..d01a7c3b00 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectedMediaProcessing.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectedMediaProcessing.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ShowImageUploadItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ShowImageUploadItem.kt index c0acfce6d7..10f5382901 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ShowImageUploadItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ShowImageUploadItem.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TakePicture.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TakePicture.kt index 00b4b5316b..b5675ee8a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TakePicture.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TakePicture.kt @@ -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 @@ -129,7 +129,7 @@ fun PictureButton(onClick: () -> Unit) { ) { Icon( imageVector = Icons.Default.CameraAlt, - contentDescription = stringRes(id = R.string.upload_image), + contentDescription = stringRes(id = R.string.take_a_picture), modifier = Modifier.height(22.dp), tint = MaterialTheme.colorScheme.onBackground, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt new file mode 100644 index 0000000000..ba98a19c45 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt @@ -0,0 +1,106 @@ +/** + * 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.ui.actions.uploads + +import android.content.Context +import android.media.MediaRecorder +import android.os.Build +import androidx.media3.common.MimeTypes +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.io.File + +class RecordingResult( + val file: File, + val mimeType: String, + val amplitudes: List, + val duration: Int, +) + +class VoiceMessageRecorder { + private var recorder: MediaRecorder? = null + private var outputFile: File? = null + private var startTime: Long = 0 + private var job: Job? = null + private var amplitudes: MutableList = mutableListOf() + + private fun createRecorder(context: Context): MediaRecorder = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(context) + } else { + MediaRecorder() + } + + suspend fun start( + context: Context, + scope: CoroutineScope, + ) { + val fileName = RandomInstance.randomChars(16) + ".mp4" + val outputFile = File(context.cacheDir, "/voice/$fileName") + outputFile.parentFile?.mkdirs() + this.outputFile = outputFile + this.startTime = TimeUtils.now() + this.amplitudes.clear() + + createRecorder(context).apply { + setAudioSource(MediaRecorder.AudioSource.MIC) + setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + setOutputFile(outputFile) + + prepare() + start() + + recorder = this + } + + job?.cancel() + job = + scope.launch { + while (recorder != null) { + amplitudes.add(((recorder?.maxAmplitude ?: 0) / 100).toInt()) + delay(1000) + } + } + } + + suspend fun stop(): RecordingResult? { + recorder?.stop() + recorder?.reset() + recorder = null + val currentTime = TimeUtils.now() + val file = outputFile + return if (currentTime - startTime >= 1 && file != null) { + RecordingResult( + file, + MimeTypes.AUDIO_AAC, + amplitudes, + (currentTime - startTime).toInt(), + ) + } else { + null + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt index 2882d41799..330dd51b20 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt @@ -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 @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.requiredHeight import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -90,7 +91,7 @@ fun AudioWaveformReadOnly( val spikeTotalWidthState = remember(spikeWidth, spikePadding) { spikeWidthState + spikePaddingState } var canvasSize by remember { mutableStateOf(Size(0f, 0f)) } - var spikes by remember { mutableStateOf(0F) } + var spikes by remember { mutableFloatStateOf(0F) } val spikesAmplitudes = remember(amplitudes, spikes, amplitudeType) { amplitudes.toDrawableAmplitudes( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt index a7aa1d7f4f..55ccb38714 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt @@ -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,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.components import android.content.Context import android.content.Intent -import android.net.Uri import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -53,11 +52,12 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat.startActivity +import androidx.core.net.toUri import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.hashtags.Cashu import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons -import com.vitorpamplona.amethyst.service.CachedCashuProcessor -import com.vitorpamplona.amethyst.service.CashuToken +import com.vitorpamplona.amethyst.service.cashu.CachedCashuParser +import com.vitorpamplona.amethyst.service.cashu.CashuToken import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.note.CopyIcon import com.vitorpamplona.amethyst.ui.note.OpenInNewIcon @@ -82,10 +82,10 @@ fun CashuPreview( ) { @Suppress("ProduceStateDoesNotAssignValue") val cashuData by produceState( - initialValue = CachedCashuProcessor.cached(cashutoken), + initialValue = CachedCashuParser.cached(cashutoken), key1 = cashutoken, ) { - val newToken = withContext(Dispatchers.Default) { CachedCashuProcessor.parse(cashutoken) } + val newToken = withContext(Dispatchers.Default) { CachedCashuParser.parse(cashutoken) } if (value != newToken) { value = newToken } @@ -209,7 +209,7 @@ fun CashuPreviewNew( FilledTonalButton( onClick = { try { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("cashu://${token.token}")) + val intent = Intent(Intent.ACTION_VIEW, "cashu://${token.token}".toUri()) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK startActivity(context, intent, null) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt index f1400a132b..2015fefcb5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt @@ -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,15 +20,29 @@ */ package com.vitorpamplona.amethyst.ui.components +import android.R.attr.onClick +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale import androidx.compose.ui.semantics.Role import com.vitorpamplona.amethyst.ui.theme.ripple24dp @@ -51,6 +65,115 @@ fun ClickableBox( } } +@Composable +fun ClickAndHoldBox( + modifier: Modifier = Modifier, + onPress: () -> Unit, + onRelease: () -> Unit, + content: @Composable (Boolean) -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + + LaunchedEffect(isPressed) { + if (isPressed) { + // Button is pressed + onPress() + } else { + // Button is released + onRelease() + } + } + + // Animation for the button scale + val scale by animateFloatAsState( + targetValue = if (isPressed) 1.5f else 1.0f, // Scale up when recording + animationSpec = tween(durationMillis = 150), // Smooth animation + ) + + // Animation for the button color + val backgroundColor by animateColorAsState( + targetValue = if (isPressed) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.background, + animationSpec = tween(durationMillis = 150), + ) + + Box( + modifier + .scale(scale) + .background(backgroundColor, CircleShape) + .clickable( + role = Role.Button, + interactionSource = interactionSource, + indication = ripple24dp, + onClick = { }, + ), + contentAlignment = Alignment.Center, + ) { + content(isPressed) + } +} + +@Composable +fun ClickAndHoldBoxComposable( + modifier: Modifier = Modifier, + onPress: @Composable () -> Unit, + onRelease: suspend () -> Unit, + onCancel: suspend () -> Unit, + content: @Composable (Boolean) -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + var isPressed by remember { mutableStateOf(false) } + + if (isPressed) { + onPress() + } + + LaunchedEffect(interactionSource) { + val pressInteractions = mutableListOf() + interactionSource.interactions.collect { interaction -> + when (interaction) { + is PressInteraction.Press -> pressInteractions.add(interaction) + is PressInteraction.Release -> { + onRelease() + pressInteractions.remove(interaction.press) + } + is PressInteraction.Cancel -> { + onCancel() + pressInteractions.remove(interaction.press) + } + } + isPressed = pressInteractions.isNotEmpty() + } + } + + // Animation for the button scale + val scale by animateFloatAsState( + targetValue = if (isPressed) 1.5f else 1.0f, // Scale up when recording + animationSpec = tween(durationMillis = 150), // Smooth animation + ) + + // Animation for the button color + val backgroundColor by animateColorAsState( + targetValue = if (isPressed) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.background, + animationSpec = tween(durationMillis = 150), + ) + + Box( + modifier + .scale(scale) + .background(backgroundColor, CircleShape) + .clickable( + role = Role.Button, + interactionSource = interactionSource, + indication = ripple24dp, + onClick = { }, + ), + contentAlignment = Alignment.Center, + ) { + content(isPressed) + } +} + @OptIn(ExperimentalFoundationApi::class) @Composable fun ClickableBox( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableEmail.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableEmail.kt index 857f1b39a2..27168e33b6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableEmail.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableEmail.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt index 48f6064b59..b197f83e81 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index 8214257d4d..1e304ab32e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -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 @@ -30,9 +30,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -58,27 +56,27 @@ import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route -import com.vitorpamplona.amethyst.ui.navigation.routeFor -import com.vitorpamplona.amethyst.ui.note.LoadChannel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.njumpLink import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay import com.vitorpamplona.quartz.nip19Bech32.entities.NSec import com.vitorpamplona.quartz.nip19Bech32.toNIP19 -import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap @@ -93,19 +91,13 @@ fun ClickableRoute( when (val entity = nip19.entity) { is NPub -> DisplayUser(entity.hex, nip19.nip19raw, nip19.additionalChars, accountViewModel, nav) is NProfile -> DisplayUser(entity.hex, nip19.nip19raw, nip19.additionalChars, accountViewModel, nav) - is com.vitorpamplona.quartz.nip19Bech32.entities.Note -> DisplayEvent(entity.hex, null, nip19.nip19raw, nip19.additionalChars, accountViewModel, nav) - is NEvent -> DisplayEvent(entity.hex, entity.kind, nip19.nip19raw, nip19.additionalChars, accountViewModel, nav) + is NNote -> DisplayEvent(entity.hex, nip19.nip19raw, nip19.additionalChars, accountViewModel, nav) + is NEvent -> DisplayEvent(entity.hex, nip19.nip19raw, nip19.additionalChars, accountViewModel, nav) is NEmbed -> LoadAndDisplayEvent(entity.event, nip19.additionalChars, accountViewModel, nav) is NAddress -> DisplayAddress(entity, nip19.nip19raw, nip19.additionalChars, accountViewModel, nav) - is NRelay -> { - Text(word) - } - is NSec -> { - Text(word) - } - else -> { - Text(word) - } + is NRelay -> Text(word) + is NSec -> Text(word) + else -> Text(word) } } @@ -136,7 +128,7 @@ private fun LoadAndDisplayEvent( ) { LoadOrCreateNote(event, accountViewModel) { if (it != null) { - DisplayNoteLink(it, event.id, event.kind, additionalChars, accountViewModel, nav) + DisplayNoteLink(it, event.id, additionalChars, accountViewModel, nav) } else { val externalLink = event.toNIP19() val uri = LocalUriHandler.current @@ -156,7 +148,6 @@ private fun LoadAndDisplayEvent( @Composable fun DisplayEvent( hex: HexKey, - kind: Int?, nip19: String, additionalChars: String?, accountViewModel: AccountViewModel, @@ -164,7 +155,7 @@ fun DisplayEvent( ) { LoadNote(hex, accountViewModel) { if (it != null) { - DisplayNoteLink(it, hex, kind, additionalChars, accountViewModel, nav) + DisplayNoteLink(it, hex, additionalChars, accountViewModel, nav) } else { val externalLink = njumpLink(nip19) val uri = LocalUriHandler.current @@ -185,56 +176,21 @@ fun DisplayEvent( private fun DisplayNoteLink( it: Note, hex: HexKey, - kind: Int?, addedCharts: String?, accountViewModel: AccountViewModel, nav: INav, ) { - val noteState by it.live().metadata.observeAsState() + val noteState by observeNote(it, accountViewModel) + val noteIdDisplayNote = remember(noteState) { "@${noteState.note.idDisplayNote()}" } - val note = remember(noteState) { noteState?.note } ?: return + val route = routeFor(it, accountViewModel.account) ?: Route.EventRedirect(hex) - val channelHex = remember(noteState) { note.channelHex() } - val noteIdDisplayNote = remember(noteState) { "@${note.idDisplayNote()}" } - - if (note.event is ChannelCreateEvent || kind == ChannelCreateEvent.KIND) { - CreateClickableText( - clickablePart = noteIdDisplayNote, - suffix = addedCharts, - route = remember(noteState) { Route.Channel(hex) }, - nav = nav, - ) - } else if (note.event is PrivateDmEvent || kind == PrivateDmEvent.KIND) { - CreateClickableText( - clickablePart = noteIdDisplayNote, - suffix = addedCharts, - route = - remember(noteState) { (note.author?.pubkeyHex ?: hex).let { Route.RoomByAuthor(it) } }, - nav = nav, - ) - } else if (channelHex != null) { - LoadChannel(baseChannelHex = channelHex, accountViewModel) { baseChannel -> - val channelState by baseChannel.live.observeAsState() - val channelDisplayName by - remember(channelState) { - derivedStateOf { channelState?.channel?.toBestDisplayName() ?: noteIdDisplayNote } - } - - CreateClickableText( - clickablePart = channelDisplayName, - suffix = addedCharts, - route = remember(noteState) { Route.Channel(baseChannel.idHex) }, - nav = nav, - ) - } - } else { - CreateClickableText( - clickablePart = noteIdDisplayNote, - suffix = addedCharts, - route = remember(noteState) { Route.EventRedirect(hex) }, - nav = nav, - ) - } + CreateClickableText( + clickablePart = noteIdDisplayNote, + suffix = addedCharts, + route = route, + nav = nav, + ) } @Composable @@ -249,15 +205,15 @@ private fun DisplayAddress( if (noteBase == null) { LaunchedEffect(key1 = nip19) { - accountViewModel.checkGetOrCreateAddressableNote(nip19.aTag()) { noteBase = it } + noteBase = accountViewModel.getOrCreateAddressableNote(nip19.address()) } } noteBase?.let { - val noteState by it.live().metadata.observeAsState() + val noteState by observeNote(it, accountViewModel) val route = remember(noteState) { Route.Note(nip19.aTag()) } - val displayName = remember(noteState) { "@${noteState?.note?.idDisplayNote()}" } + val displayName = remember(noteState) { "@${noteState.note.idDisplayNote()}" } CreateClickableText( clickablePart = displayName, @@ -282,7 +238,7 @@ private fun DisplayAddress( } @Composable -public fun DisplayUser( +fun DisplayUser( userHex: HexKey, originalNip19: String, additionalChars: String?, @@ -302,7 +258,7 @@ public fun DisplayUser( } } - userBase?.let { RenderUserAsClickableText(it, additionalChars, nav) } + userBase?.let { RenderUserAsClickableText(it, additionalChars, accountViewModel, nav) } if (userBase == null) { val uri = LocalUriHandler.current @@ -319,15 +275,16 @@ public fun DisplayUser( } @Composable -public fun RenderUserAsClickableText( +fun RenderUserAsClickableText( baseUser: User, additionalChars: String?, + accountViewModel: AccountViewModel, nav: INav, ) { - val userState by baseUser.live().userMetadataInfo.observeAsState() + val userState by observeUserInfo(baseUser, accountViewModel) CreateClickableTextWithEmoji( - clickablePart = userState?.bestName() ?: ("@" + baseUser.pubkeyDisplayHex()), + clickablePart = "@" + (userState?.bestName() ?: baseUser.pubkeyDisplayHex()), suffix = additionalChars?.ifBlank { null }, maxLines = 1, route = remember(baseUser) { routeFor(baseUser) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableTexts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableTexts.kt index aabb06fe8d..ea21a645ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableTexts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableTexts.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt index e55a0057e3..2e30bf7c2f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt @@ -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,8 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.components -import android.R.attr.maxLines -import android.R.attr.onClick import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.style.TextOverflow diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt index faecd0ba79..b8ee8af0f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt index 1eb8b44544..6d6b55a9c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt @@ -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 @@ -41,7 +41,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.richtext.ExpandableTextCutOffCalculator -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.getGradient import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ForwardingPainter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ForwardingPainter.kt index ab6cabd43b..e2f03398a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ForwardingPainter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ForwardingPainter.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GenericLoadable.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GenericLoadable.kt index f2eb7d9e1e..9e59c0c8e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GenericLoadable.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GenericLoadable.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GlowingCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GlowingCard.kt index beae54a704..2485522b0f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GlowingCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GlowingCard.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt index 8c8e28174a..23b9998c68 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt @@ -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 @@ -73,6 +73,11 @@ fun LoadUrlPreviewDirect( is UrlPreviewState.Loaded -> { RenderLoaded(state, url, callbackUri, accountViewModel) } + is UrlPreviewState.Loading -> { + WaitAndDisplay { + DisplayUrlWithLoadingSymbol(url) + } + } else -> { ClickableUrl(urlText, url) } @@ -87,7 +92,7 @@ fun RenderLoaded( callbackUri: String? = null, accountViewModel: AccountViewModel, ) { - if (state.previewInfo.mimeType.type == "image") { + if (state.previewInfo.mimeType.startsWith("image")) { Box(modifier = HalfVertPadding) { ZoomableContentView( content = MediaUrlImage(url, uri = callbackUri), @@ -96,7 +101,7 @@ fun RenderLoaded( accountViewModel = accountViewModel, ) } - } else if (state.previewInfo.mimeType.type == "video") { + } else if (state.previewInfo.mimeType.startsWith("video")) { Box(modifier = HalfVertPadding) { ZoomableContentView( content = MediaUrlVideo(url, uri = callbackUri), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadingAnimation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadingAnimation.kt index 1e4f2176cf..e9541b4ab2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadingAnimation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadingAnimation.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt new file mode 100644 index 0000000000..4e847ec37f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt @@ -0,0 +1,138 @@ +/** + * 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.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.size +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import coil3.compose.AsyncImagePainter +import coil3.compose.SubcomposeAsyncImage +import coil3.compose.SubcomposeAsyncImageContent +import com.vitorpamplona.amethyst.model.MediaAspectRatioCache +import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled +import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size40dp +import com.vitorpamplona.amethyst.ui.theme.Size6dp +import com.vitorpamplona.amethyst.ui.theme.Size75dp + +@Composable +fun MyAsyncImage( + imageUrl: String, + contentDescription: String?, + contentScale: ContentScale, + mainImageModifier: Modifier, + loadedImageModifier: Modifier, + accountViewModel: AccountViewModel, + onLoadingBackground: (@Composable () -> Unit)?, + onError: (@Composable () -> Unit)?, +) { + val ratio = MediaAspectRatioCache.get(imageUrl) + val showImage = remember { mutableStateOf(accountViewModel.settings.showImages.value) } + + CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) { + if (it) { + SubcomposeAsyncImage( + model = imageUrl, + contentDescription = contentDescription, + contentScale = contentScale, + modifier = mainImageModifier, + ) { + val state by painter.state.collectAsState() + when (state) { + is AsyncImagePainter.State.Loading -> { + if (ratio != null) { + Box(loadedImageModifier.aspectRatio(ratio), contentAlignment = Alignment.Center) { + LoadingAnimation(Size40dp, Size6dp) + } + } else { + WaitAndDisplay { + if (onLoadingBackground != null) { + Box(loadedImageModifier, contentAlignment = Alignment.Center) { + onLoadingBackground() + LoadingAnimation(Size40dp, Size6dp) + } + } else { + DisplayUrlWithLoadingSymbol(imageUrl) + } + } + } + } + is AsyncImagePainter.State.Error -> { + if (onError != null) { + if (ratio != null) { + Box(loadedImageModifier.aspectRatio(ratio), contentAlignment = Alignment.Center) { + onError() + } + } else { + Box(loadedImageModifier, contentAlignment = Alignment.Center) { + onError() + } + } + } else { + ClickableUrl(urlText = imageUrl, url = imageUrl) + } + } + is AsyncImagePainter.State.Success -> { + SubcomposeAsyncImageContent(loadedImageModifier) + + SideEffect { + val drawable = (state as AsyncImagePainter.State.Success).result.image + MediaAspectRatioCache.add(imageUrl, drawable.width, drawable.height) + } + } + else -> {} + } + } + } else { + if (ratio != null) { + Box(loadedImageModifier.aspectRatio(ratio), contentAlignment = Alignment.Center) { + IconButton( + modifier = Modifier.size(Size75dp), + onClick = { showImage.value = true }, + ) { + DownloadForOfflineIcon(Size75dp, MaterialTheme.colorScheme.onBackground) + } + } + } else { + Box(loadedImageModifier.aspectRatio(16 / 9.0f), contentAlignment = Alignment.Center) { + IconButton( + modifier = Modifier.size(Size75dp), + onClick = { showImage.value = true }, + ) { + DownloadForOfflineIcon(Size75dp, MaterialTheme.colorScheme.onBackground) + } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/NonlazyGridPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/NonlazyGridPreview.kt index 4613d550fa..72b12336d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/NonlazyGridPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/NonlazyGridPreview.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index b87492a5c4..da20d34cd3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -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 @@ -40,7 +40,6 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -82,28 +81,33 @@ import com.vitorpamplona.amethyst.commons.richtext.SecretEmoji import com.vitorpamplona.amethyst.commons.richtext.Segment import com.vitorpamplona.amethyst.commons.richtext.WithdrawSegment import com.vitorpamplona.amethyst.model.HashtagIcon +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon import com.vitorpamplona.amethyst.service.CachedRichTextParser +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav.nav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.creators.invoice.MayBeInvoicePreview -import com.vitorpamplona.amethyst.ui.note.toShortenHex +import com.vitorpamplona.amethyst.ui.note.toShortDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.theme.CashuCardBorders import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.inlinePlaceholder import com.vitorpamplona.amethyst.ui.theme.innerPostModifier import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -164,6 +168,7 @@ fun RenderStrangeNamePreview() { @Composable fun RenderRegularPreview() { val nav = EmptyNav + val accountViewModel = mockAccountViewModel() Column(modifier = Modifier.padding(10.dp)) { RenderRegular( @@ -190,7 +195,7 @@ fun RenderRegularPreview() { ) } - is HashTagSegment -> HashTag(word, nav) + is HashTagSegment -> HashTag(word, accountViewModel, nav) // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) @@ -204,6 +209,7 @@ fun RenderRegularPreview() { @Composable fun RenderRegularPreview2() { val nav = EmptyNav + val accountViewModel = mockAccountViewModel() RenderRegular( "#Amethyst v0.84.1: ncryptsec support (NIP-49)", EmptyTagList, @@ -218,7 +224,7 @@ fun RenderRegularPreview2() { is EmailSegment -> ClickableEmail(word.segmentText) is PhoneSegment -> ClickablePhone(word.segmentText) // is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav) - is HashTagSegment -> HashTag(word, nav) + is HashTagSegment -> HashTag(word, accountViewModel, nav) // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) @@ -259,7 +265,7 @@ fun RenderRegularPreview3() { is EmailSegment -> ClickableEmail(word.segmentText) is PhoneSegment -> ClickablePhone(word.segmentText) // is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav) - is HashTagSegment -> HashTag(word, nav) + is HashTagSegment -> HashTag(word, accountViewModel, nav) // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) @@ -386,7 +392,7 @@ private fun RenderWordWithoutPreview( is SecretEmoji -> Text(word.segmentText) is PhoneSegment -> ClickablePhone(word.segmentText) is BechSegment -> BechLink(word.segmentText, false, 0, backgroundColor, accountViewModel, nav) - is HashTagSegment -> HashTag(word, nav) + is HashTagSegment -> HashTag(word, accountViewModel, nav) is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) is HashIndexEventSegment -> TagLink(word, false, 0, backgroundColor, accountViewModel, nav) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) @@ -415,7 +421,7 @@ private fun RenderWordWithPreview( is SecretEmoji -> DisplaySecretEmoji(word, state, callbackUri, true, quotesLeft, backgroundColor, accountViewModel, nav) is PhoneSegment -> ClickablePhone(word.segmentText) is BechSegment -> BechLink(word.segmentText, true, quotesLeft, backgroundColor, accountViewModel, nav) - is HashTagSegment -> HashTag(word, nav) + is HashTagSegment -> HashTag(word, accountViewModel, nav) is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) is HashIndexEventSegment -> TagLink(word, true, quotesLeft, backgroundColor, accountViewModel, nav) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) @@ -620,6 +626,7 @@ fun CoreSecretMessage( @Composable fun HashTag( segment: HashTagSegment, + accountViewModel: AccountViewModel, nav: INav, ) { val primary = MaterialTheme.colorScheme.primary @@ -724,7 +731,7 @@ fun TagLink( ) { LoadNote(baseNoteHex = word.hex, accountViewModel) { if (it == null) { - Text(text = remember { word.segmentText.toShortenHex() }) + Text(text = remember { word.segmentText.toShortDisplay() }) } else { Row { DisplayNoteFromTag( @@ -741,6 +748,32 @@ fun TagLink( } } +@Preview +@Composable +fun DisplayNoteFromTagPreview() { + val dummyPost = + TextNoteEvent( + id = "0b6d941c46411a95edb1c93da7ad6ca26370497d8c7b7d621f5cb59f48841bad", + pubKey = "6dd3b72e325da7383b275eef1c66131ba4664326e162bc060527509b4e33ae43", + createdAt = 1753988264, + tags = emptyArray(), + content = "test", + sig = "ec39e60722a083cccbd2d82d2827e13f5499fa7cbcedac5b76011a844c077473adb629d50d01fab147835ac6c8a3d5ba9aaddd87d6723f0c3c864b9119fc4356", + ) + + LocalCache.justConsume(dummyPost, null, true) + val note = LocalCache.getOrCreateNote(dummyPost.id) + + ThemeComparisonColumn( + toPreview = { + ClickableTextPrimary( + text = "@${note.idNote().toShortDisplay()}", + onClick = { }, + ) + }, + ) +} + @Composable private fun DisplayNoteFromTag( baseNote: Note, @@ -763,8 +796,8 @@ private fun DisplayNoteFromTag( ) } else { ClickableTextPrimary( - text = "@${baseNote.idNote().toShortenHex()}", - onClick = { routeFor(baseNote, accountViewModel.userProfile())?.let { nav.nav(it) } }, + text = "@${baseNote.idNote().toShortDisplay()}", + onClick = { routeFor(baseNote, accountViewModel.account)?.let { nav.nav(it) } }, ) } @@ -777,7 +810,7 @@ private fun DisplayUserFromTag( accountViewModel: AccountViewModel, nav: INav, ) { - val meta by baseUser.live().userMetadataInfo.observeAsState(baseUser.info) + val meta by observeUserInfo(baseUser, accountViewModel) CrossfadeIfEnabled(targetState = meta, label = "DisplayUserFromTag", accountViewModel = accountViewModel) { Row { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt index a091e397fd..2649b4700d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SelectTextDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SelectTextDialog.kt deleted file mode 100644 index db8f877e6d..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SelectTextDialog.kt +++ /dev/null @@ -1,88 +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.ui.components - -import android.content.res.Configuration -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Card -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.IconButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.Size24dp - -@Composable -fun SelectTextDialog( - text: String, - onDismiss: () -> Unit, -) { - val screenHeight = LocalConfiguration.current.screenHeightDp.dp - val maxHeight = - if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT) { - screenHeight * 0.6f - } else { - screenHeight * 0.9f - } - - Dialog( - onDismissRequest = onDismiss, - ) { - Card { - Column( - modifier = Modifier.heightIn(Size24dp, maxHeight), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End, - ) { - IconButton( - onClick = onDismiss, - ) { - ArrowBackIcon() - } - Text(text = stringRes(R.string.select_text_dialog_top)) - } - HorizontalDivider(thickness = DividerThickness) - Column( - modifier = Modifier.verticalScroll(rememberScrollState()), - ) { - Row(modifier = Modifier.padding(16.dp)) { SelectionContainer { Text(text) } } - } - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt index b73e569576..17181f90c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SetDialogToEdgeToEdge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SetDialogToEdgeToEdge.kt index 9ce8e80b14..d974cc4723 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SetDialogToEdgeToEdge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SetDialogToEdgeToEdge.kt @@ -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,27 +23,38 @@ package com.vitorpamplona.amethyst.ui.components import android.view.View import android.view.WindowManager import android.widget.FrameLayout +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.ui.platform.LocalView import androidx.compose.ui.window.DialogWindowProvider +import androidx.core.view.WindowCompat +import com.vitorpamplona.amethyst.ui.theme.isLight @Composable fun SetDialogToEdgeToEdge() { val activityWindow = getActivityWindow() val dialogWindow = (LocalView.current.parent as? DialogWindowProvider)?.window val parentView = LocalView.current.parent as View + val isLight = MaterialTheme.colorScheme.isLight + SideEffect { if (activityWindow != null && dialogWindow != null) { val attributes = WindowManager.LayoutParams() attributes.copyFrom(activityWindow.attributes) attributes.type = dialogWindow.attributes.type dialogWindow.attributes = attributes + dialogWindow.statusBarColor parentView.layoutParams = FrameLayout.LayoutParams( activityWindow.decorView.width, activityWindow.decorView.height, ) + + val insets = WindowCompat.getInsetsController(dialogWindow, parentView) + + insets.isAppearanceLightNavigationBars = isLight + insets.isAppearanceLightStatusBars = isLight } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt new file mode 100644 index 0000000000..279308bfc8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt @@ -0,0 +1,135 @@ +/** + * 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.ui.components + +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.core.content.FileProvider +import com.vitorpamplona.amethyst.Amethyst +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileInputStream +import java.io.IOException + +object ShareHelper { + private const val TAG = "ShareHelper" + private const val DEFAULT_EXTENSION = "jpg" + private const val SHARED_FILE_PREFIX = "shared_media" + + // Media type magic numbers + private val JPEG_MAGIC = byteArrayOf(0xFF.toByte(), 0xD8.toByte()) + private val PNG_MAGIC = byteArrayOf(0x89.toByte(), 0x50.toByte(), 0x4E.toByte(), 0x47.toByte()) + private val WEBP_HEADER_START = "RIFF".toByteArray() + private val WEBP_HEADER_END = "WEBP".toByteArray() + private val GIF_MAGIC = "GIF8".toByteArray() + + suspend fun getSharableUriFromUrl( + context: Context, + imageUrl: String, + ): Pair = + withContext(Dispatchers.IO) { + // Safely get snapshot and file + Amethyst.instance.diskCache.openSnapshot(imageUrl)?.use { snapshot -> + val file = snapshot.data.toFile() + + // Determine file extension and prepare sharable file + val fileExtension = getImageExtension(file) + val fileCopy = prepareSharableFile(context, file, fileExtension) + + // Return sharable uri + return@use Pair( + FileProvider.getUriForFile(context, "${context.packageName}.provider", fileCopy), + fileExtension, + ) + } ?: throw IOException("Unable to open snapshot for: $imageUrl") + } + + private fun getImageExtension(file: File): String = + try { + FileInputStream(file).use { inputStream -> + val header = ByteArray(12) + val bytesRead = inputStream.read(header) + + if (bytesRead < 4) { + // If we couldn't read at least 4 bytes, default to jpg + return DEFAULT_EXTENSION + } + + when { + // JPEG: Check first 2 bytes + matchesMagicNumbers(header, 0, JPEG_MAGIC) -> "jpg" + + // PNG: Check first 4 bytes + matchesMagicNumbers(header, 0, PNG_MAGIC) -> "png" + + // GIF: Check first 4 bytes for "GIF8" + matchesMagicNumbers(header, 0, GIF_MAGIC) -> "gif" + + // WEBP: Check "RIFF" (bytes 0-3) and "WEBP" (bytes 8-11) + matchesMagicNumbers(header, 0, WEBP_HEADER_START) && + bytesRead >= 12 && + matchesMagicNumbers(header, 8, WEBP_HEADER_END) -> "webp" + + else -> DEFAULT_EXTENSION + } + } + } catch (e: IOException) { + Log.w(TAG, "Could not determine image type for ${file.name}, defaulting to $DEFAULT_EXTENSION", e) + DEFAULT_EXTENSION + } + + private fun matchesMagicNumbers( + data: ByteArray, + offset: Int, + magicBytes: ByteArray, + ): Boolean { + if (offset + magicBytes.size > data.size) { + return false + } + + for (i in magicBytes.indices) { + if (data[offset + i] != magicBytes[i]) { + return false + } + } + return true + } + + private fun prepareSharableFile( + context: Context, + originalFile: File, + extension: String, + ): File { + val timestamp = System.currentTimeMillis() + val sharableFile = File(context.cacheDir, "${SHARED_FILE_PREFIX}_$timestamp.$extension") + + try { + originalFile.copyTo(sharableFile, overwrite = true) + } catch (e: IOException) { + Log.e(TAG, "Failed to copy file for sharing", e) + throw e + } + + return sharableFile + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt index 047bb7becd..e161844a51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SwipeToDelete.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SwipeToDelete.kt index 72db84e34d..6d45182428 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SwipeToDelete.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SwipeToDelete.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt index 07411c4a7e..954e6a5a28 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ThinPaddingTextField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ThinPaddingTextField.kt index f11dce9658..c51d143594 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ThinPaddingTextField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ThinPaddingTextField.kt @@ -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 @@ -119,13 +119,15 @@ fun ThinPaddingTextField( modifier .defaultMinSize( minWidth = TextFieldDefaults.MinWidth, - minHeight = 36.dp, // this has changed + // this has changed + minHeight = 36.dp, ), onValueChange = onValueChange, enabled = enabled, readOnly = readOnly, textStyle = mergedTextStyle, - cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), // this has changed + // this has changed + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), visualTransformation = visualTransformation, keyboardOptions = keyboardOptions, keyboardActions = keyboardActions, @@ -152,7 +154,8 @@ fun ThinPaddingTextField( isError = isError, interactionSource = interactionSource, colors = colors, - contentPadding = contentPadding, // this has changed + // this has changed + contentPadding = contentPadding, ) }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/TranslationConfig.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/TranslationConfig.kt index 379b171a33..85b458ed4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/TranslationConfig.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/TranslationConfig.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt index 2753d6f46c..aab99a11a3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewState.kt index 533acdef20..f8784de78f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewState.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/WindowUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/WindowUtils.kt index 339ce9dcfb..163c895e24 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/WindowUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/WindowUtils.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index 717b031dd6..bfc5b8a36e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -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 @@ -50,7 +50,6 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextFieldDefaults.contentPadding import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -66,7 +65,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.core.net.toUri -import coil3.compose.AsyncImage import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState @@ -267,7 +265,7 @@ private fun DialogContent( Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || writeStoragePermissionState.status.isGranted ) { - scope.launch { + scope.launch(Dispatchers.IO) { saveMediaToGallery(myContent, localContext, accountViewModel) } scope.launch { @@ -297,7 +295,7 @@ private fun DialogContent( } } -private fun saveMediaToGallery( +private suspend fun saveMediaToGallery( content: BaseMediaContent, localContext: Context, accountViewModel: AccountViewModel, @@ -343,43 +341,6 @@ private fun saveMediaToGallery( } } -@Composable -fun InlineCarrousel( - allImages: ImmutableList, - imageUrl: String, -) { - val pagerState: PagerState = rememberPagerState { allImages.size } - - LaunchedEffect(key1 = pagerState, key2 = imageUrl) { - launch { - val page = allImages.indexOf(imageUrl) - if (page > -1) { - pagerState.scrollToPage(page) - } - } - } - - if (allImages.size > 1) { - SlidingCarousel( - pagerState = pagerState, - ) { index -> - AsyncImage( - model = allImages[index], - contentDescription = null, - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth(), - ) - } - } else { - AsyncImage( - model = imageUrl, - contentDescription = null, - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth(), - ) - } -} - @Composable private fun RenderImageOrVideo( content: BaseMediaContent, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 34fcb00d2c..1b79f32b33 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -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,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.components +import android.content.Context +import android.content.Intent import android.util.Log import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibilityScope @@ -34,12 +36,14 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Report import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState @@ -48,6 +52,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -56,7 +61,6 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString @@ -83,16 +87,14 @@ import com.vitorpamplona.amethyst.service.images.BlurhashWrapper import com.vitorpamplona.amethyst.service.playback.composable.VideoView import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.InformationDialog -import com.vitorpamplona.amethyst.ui.components.util.DeviceUtils import com.vitorpamplona.amethyst.ui.note.BlankNote import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon -import com.vitorpamplona.amethyst.ui.note.HashCheckFailedIcon -import com.vitorpamplona.amethyst.ui.note.HashCheckIcon +import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.Size24dp -import com.vitorpamplona.amethyst.ui.theme.Size30dp +import com.vitorpamplona.amethyst.ui.theme.Size30Modifier import com.vitorpamplona.amethyst.ui.theme.Size40dp import com.vitorpamplona.amethyst.ui.theme.Size6dp import com.vitorpamplona.amethyst.ui.theme.Size75dp @@ -107,6 +109,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.time.Duration.Companion.seconds @@ -120,13 +123,6 @@ fun ZoomableContentView( ) { var dialogOpen by remember(content) { mutableStateOf(false) } - val activity = LocalView.current.context.getActivity() - val currentWindowSize = currentWindowAdaptiveInfo().windowSizeClass - - val isLandscapeMode = DeviceUtils.isLandscapeMetric(LocalContext.current) - val isFoldableOrLarge = DeviceUtils.windowIsLarge(windowSize = currentWindowSize, isInLandscapeMode = isLandscapeMode) - val isOrientationLocked = DeviceUtils.screenOrientationIsLocked(LocalContext.current) - when (content) { is MediaUrlImage -> SensitivityWarning(content.contentWarning != null, accountViewModel) { @@ -155,9 +151,6 @@ fun ZoomableContentView( nostrUriCallback = content.uri, onDialog = { dialogOpen = true - // if (!isFoldableOrLarge && !isOrientationLocked) { - // DeviceUtils.changeDeviceOrientation(isLandscapeMode, activity) - // } }, accountViewModel = accountViewModel, ) @@ -198,7 +191,6 @@ fun ZoomableContentView( images, onDismiss = { dialogOpen = false - // if (!isFoldableOrLarge && !isOrientationLocked) DeviceUtils.changeDeviceOrientation(isLandscapeMode, activity) }, accountViewModel, ) @@ -624,6 +616,38 @@ fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) { } } +@Composable +fun DisplayUrlWithLoadingSymbol(url: String) { + val uri = LocalUriHandler.current + + val primary = MaterialTheme.colorScheme.primary + val annotatedTermsString = + remember { + buildAnnotatedString { + withStyle(SpanStyle(color = primary)) { + pushStringAnnotation("routeToImage", "") + append("$url ") + pop() + } + } + } + + val pressIndicator = remember { Modifier.clickable { runCatching { uri.openUri(url) } } } + + Row( + modifier = Modifier.width(IntrinsicSize.Max), + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = annotatedTermsString, + modifier = pressIndicator.weight(1f, fill = false), + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + InlineLoadingIcon() + } +} + @Composable private fun InlineLoadingIcon() = LoadingAnimation() @@ -662,6 +686,7 @@ fun ShareImageAction( hash = content.hash, mimeType = content.mimeType, onDismiss = onDismiss, + content = content, ) } else if (content is MediaPreloadedContent) { ShareImageAction( @@ -674,6 +699,7 @@ fun ShareImageAction( hash = null, mimeType = content.mimeType, onDismiss = onDismiss, + content = content, ) } } @@ -690,7 +716,10 @@ fun ShareImageAction( hash: String?, mimeType: String?, onDismiss: () -> Unit, + content: BaseMediaContent? = null, ) { + val scope = rememberCoroutineScope() + DropdownMenu( expanded = popupExpanded.value, onDismissRequest = onDismiss, @@ -733,10 +762,49 @@ fun ShareImageAction( }, ) } + + content?.let { + if (content is MediaUrlImage) { + val context = LocalContext.current + videoUri?.let { + if (videoUri.isNotEmpty()) { + DropdownMenuItem( + text = { Text(stringRes(R.string.share_image)) }, + onClick = { + scope.launch { shareImageFile(context, videoUri, mimeType) } + onDismiss() + }, + ) + } + } + } + } } } -private suspend fun verifyHash(content: MediaUrlContent): Boolean? { +private suspend fun shareImageFile( + context: Context, + videoUri: String, + mimeType: String?, +) { + // Get sharable URI and file extension + val (uri, fileExtension) = ShareHelper.getSharableUriFromUrl(context, videoUri) + + // Determine mime type, use provided or derive from extension + val determinedMimeType = mimeType ?: "image/$fileExtension" + + // Create share intent + val shareIntent = + Intent(Intent.ACTION_SEND).apply { + type = determinedMimeType + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + context.startActivity(Intent.createChooser(shareIntent, null)) +} + +private fun verifyHash(content: MediaUrlContent): Boolean? { if (content.hash == null) return null Amethyst.instance.diskCache.openSnapshot(content.url)?.use { snapshot -> @@ -771,7 +839,12 @@ private fun HashVerificationSymbol(verifiedHash: Boolean) { openDialogMsg.value = stringRes(localContext, R.string.hash_verification_passed) }, ) { - HashCheckIcon(Size30dp) + Icon( + painter = painterRes(R.drawable.original, 1), + contentDescription = stringRes(id = R.string.hash_verification_passed), + modifier = Size30Modifier, + tint = Color.Unspecified, + ) } } else { IconButton( @@ -780,7 +853,12 @@ private fun HashVerificationSymbol(verifiedHash: Boolean) { openDialogMsg.value = stringRes(localContext, R.string.hash_verification_failed) }, ) { - HashCheckFailedIcon(Size30dp) + Icon( + imageVector = Icons.Default.Report, + contentDescription = stringRes(id = R.string.hash_verification_failed), + modifier = Size30Modifier, + tint = Color.Red, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt index 2f31da4e89..70468d31ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt @@ -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,7 +25,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.PlaceholderVerticalAlign @@ -39,11 +38,12 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.HashtagIcon import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.components.DisplayFullNote import com.vitorpamplona.amethyst.ui.components.DisplayUser import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview import com.vitorpamplona.amethyst.ui.components.ZoomableContentView -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.amethyst.ui.screen.loggedIn.LoadedBechLink import com.vitorpamplona.amethyst.ui.theme.Font17SP @@ -75,15 +75,15 @@ class MarkdownMediaRenderer( uri: String, ): Boolean = if (canPreview && uri.startsWith("http")) { - if (title.isNullOrBlank() || title == uri) { - true - } else { - false - } + title.isNullOrBlank() || title == uri } else { false } + override fun shouldSanitizeUriLabel(): Boolean = true + + override fun sanitizeUriLabel(label: String): String = label.filterNot { it == '#' || it == '@' } + override fun renderImage( title: String?, uri: String, @@ -171,7 +171,7 @@ class MarkdownMediaRenderer( when (val entity = loadedLink.nip19.entity) { is NPub -> renderObservableUser(entity.hex, loadedLink.nip19.nip19raw, richTextStringBuilder) is NProfile -> renderObservableUser(entity.hex, loadedLink.nip19.nip19raw, richTextStringBuilder) - is com.vitorpamplona.quartz.nip19Bech32.entities.Note -> renderObservableShortNoteUri(loadedLink, uri, richTextStringBuilder) + is com.vitorpamplona.quartz.nip19Bech32.entities.NNote -> renderObservableShortNoteUri(loadedLink, uri, richTextStringBuilder) is NEvent -> renderObservableShortNoteUri(loadedLink, uri, richTextStringBuilder) is NEmbed -> renderObservableShortNoteUri(loadedLink, uri, richTextStringBuilder) is NAddress -> renderObservableShortNoteUri(loadedLink, uri, richTextStringBuilder) @@ -189,7 +189,7 @@ class MarkdownMediaRenderer( richTextStringBuilder: RichTextString.Builder, ) { val tagWithoutHash = tag.removePrefix("#") - renderAsCompleteLink(tag, "nostr:nashtag?id=$tagWithoutHash", richTextStringBuilder) + renderAsCompleteLink(tag, "nostr:hashtag?id=$tagWithoutHash", richTextStringBuilder) val hashtagIcon: HashtagIcon? = checkForHashtagWithIcon(tagWithoutHash) if (hashtagIcon != null) { @@ -231,7 +231,7 @@ class MarkdownMediaRenderer( ) { renderInvisible(richTextStringBuilder) { // Preloads note if not loaded yet. - baseNote.live().metadata.observeAsState() + EventFinderFilterAssemblerSubscription(baseNote, accountViewModel) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt index 6c51042162..a70b427f20 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt @@ -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 @@ -41,8 +41,8 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.UrlCachedPreviewer import com.vitorpamplona.amethyst.service.previews.UrlInfoItem import com.vitorpamplona.amethyst.ui.components.UrlPreviewState -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.theme.MarkdownTextStyle @@ -57,7 +57,6 @@ import com.vitorpamplona.quartz.nip92IMeta.imetasByUrl import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext -import okhttp3.MediaType.Companion.toMediaType @Composable fun RenderContentAsMarkdown( @@ -74,7 +73,7 @@ fun RenderContentAsMarkdown( val onClick = remember { { link: String -> - val route = uriToRoute(link) + val route = uriToRoute(link, accountViewModel.account) if (route != null) { nav.nav(route) } else { @@ -375,12 +374,12 @@ fun RenderContentAsMarkdownUserPreview() { image = "https://duckduckgo.com/assets/logo_social-media.png", title = "DuckDuckGo — Privacy, simplified.", description = "The Internet privacy company that empowers you to seamlessly take control of your personal information online, without any tradeoffs.", - mimeType = "text/html".toMediaType(), + mimeType = "text/html", ), ), ) - LocalCache.justConsume(qa, null) + LocalCache.justConsume(qa, null, false) } } @@ -429,7 +428,7 @@ fun RenderContentAsMarkdownNotePreview() { sig = "4c85e0eb0c46c5e3023431ad4ed8efa0abd66447ff757d246154e2349ac01ae0f88f213d02efa0a77f307f305d4a608c785ae1ca080c01cd3a9e7b8dffea6f9c", ) - LocalCache.justConsume(blogPost, null) + LocalCache.justConsume(blogPost, null, false) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/DisplayErrorMessages.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/DisplayErrorMessages.kt index ac29855cdd..2f0fd947af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/DisplayErrorMessages.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/DisplayErrorMessages.kt @@ -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,7 +25,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.ui.actions.InformationDialog import com.vitorpamplona.amethyst.ui.components.toasts.multiline.MultiErrorToastMsg import com.vitorpamplona.amethyst.ui.components.toasts.multiline.MultiUserErrorMessageDialog -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.amethyst.ui.stringRes diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ResourceToastMsg.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ResourceToastMsg.kt index 1ca182da04..fda5ae3d08 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ResourceToastMsg.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ResourceToastMsg.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/StringToastMsg.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/StringToastMsg.kt index 1cd090262f..a0d21c73f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/StringToastMsg.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/StringToastMsg.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ThrowableToastMsg.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ThrowableToastMsg.kt index 1ee9f555c2..31f673ef33 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ThrowableToastMsg.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ThrowableToastMsg.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ToastManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ToastManager.kt index 95e1830e1c..6a84d2695a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ToastManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ToastManager.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ToastMsg.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ToastMsg.kt index 285ae8af6d..f9570718c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ToastMsg.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ToastMsg.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/multiline/ErrorList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/multiline/ErrorList.kt index a822bde136..ae1d16be90 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/multiline/ErrorList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/multiline/ErrorList.kt @@ -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 @@ -39,16 +39,16 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeToMessage +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -140,7 +140,7 @@ fun ErrorRow( } ?: stringRes(R.string.error_dialog_talk_to_user) Icon( - painter = painterResource(R.drawable.ic_dm), + painter = painterRes(R.drawable.ic_dm, 2), contentDescription = descriptor, modifier = Size20Modifier, tint = MaterialTheme.colorScheme.primary, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/multiline/MultiErrorToastMsg.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/multiline/MultiErrorToastMsg.kt index ff42feb671..7bf27518de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/multiline/MultiErrorToastMsg.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/multiline/MultiErrorToastMsg.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/multiline/MultiUserErrorMessageDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/multiline/MultiUserErrorMessageDialog.kt index edf0dea2b7..85e7cc817d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/multiline/MultiUserErrorMessageDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/multiline/MultiUserErrorMessageDialog.kt @@ -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 @@ -29,13 +29,12 @@ import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/DeviceUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/DeviceUtils.kt index 3e3f3d11cf..c148d6b6a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/DeviceUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/DeviceUtils.kt @@ -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 @@ -30,6 +30,7 @@ import androidx.compose.runtime.remember import androidx.window.core.layout.WindowHeightSizeClass import androidx.window.core.layout.WindowSizeClass import androidx.window.core.layout.WindowWidthSizeClass +import com.vitorpamplona.amethyst.ui.components.util.DeviceUtils.screenOrientationIsLocked object DeviceUtils { /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/MediaCompressorFileUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/MediaCompressorFileUtils.kt index be8eb0ab45..325a327306 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/MediaCompressorFileUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/MediaCompressorFileUtils.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/RelayState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/AdditiveComplexFeedFilter.kt similarity index 81% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/RelayState.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/AdditiveComplexFeedFilter.kt index 49ea625d31..3a00f7f491 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/RelayState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/AdditiveComplexFeedFilter.kt @@ -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,15 +18,11 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core.relay +package com.vitorpamplona.amethyst.ui.dal -enum class RelayState { - // Websocket connected - CONNECTED, - - // Websocket disconnecting - DISCONNECTING, - - // Websocket disconnected - DISCONNECTED, +abstract class AdditiveComplexFeedFilter : FeedFilter() { + abstract fun updateListWith( + oldList: List, + newItems: Set, + ): List } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/AdditiveFeedFilter.kt similarity index 57% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayService.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/AdditiveFeedFilter.kt index 9f45038746..b11d4651c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/AdditiveFeedFilter.kt @@ -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,31 +18,28 @@ * 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.ui.dal -import android.content.Context -import android.util.Log -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.launch +import com.vitorpamplona.amethyst.logTime -class RelayService( - val context: Context, -) { - val status = - callbackFlow { - Log.d("RelayService", "Starting Relay Services") - trySend(RelayServiceStatus.Connecting) +abstract class AdditiveFeedFilter : FeedFilter() { + abstract fun applyFilter(collection: Set): Set - // ServiceManager + abstract fun sort(collection: Set): List - awaitClose { - Log.d("RelayService", "Stopping Relay Services") - launch { - // ServiceManager.pauseAndLogOff() - } - trySend(RelayServiceStatus.Off) + open fun updateListWith( + oldList: List, + newItems: Set, + ): List = + logTime( + debugMessage = { "${this.javaClass.simpleName} AdditiveFeedFilter updating ${newItems.size} new items to ${it.size} items" }, + ) { + val newItemsToBeAdded = applyFilter(newItems) + if (newItemsToBeAdded.isNotEmpty()) { + val newList = oldList.toSet() + newItemsToBeAdded + sort(newList).take(limit()) + } else { + oldList } - }.distinctUntilChanged() + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DefaultFeedOrder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DefaultFeedOrder.kt index 6391031f94..72f1e65b86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DefaultFeedOrder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DefaultFeedOrder.kt @@ -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,22 +22,9 @@ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.Card -import com.vitorpamplona.quartz.nip01Core.core.Event val DefaultFeedOrder: Comparator = - compareByDescending - { - val noteEvent = it.event - if (noteEvent == null) { - null - } else { - if (noteEvent is Event) { - noteEvent.createdAt - } else { - null - } - } - }.thenBy { it.idHex } + compareByDescending { it.createdAt() }.thenBy { it.idHex } val DefaultFeedOrderCard: Comparator = compareByDescending { it.createdAt() }.thenBy { it.id() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt index 0bb8ca70a1..d6a71a6c2e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt @@ -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,54 +20,24 @@ */ package com.vitorpamplona.amethyst.ui.dal -import android.util.Log -import com.vitorpamplona.amethyst.service.checkNotInMainThread -import kotlin.time.measureTimedValue +import com.vitorpamplona.amethyst.logTime abstract class FeedFilter { fun loadTop(): List { - checkNotInMainThread() - - val (feed, elapsed) = measureTimedValue { feed() } - - Log.d("Time", "${this.javaClass.simpleName} Full Feed in $elapsed with ${feed.size} objects") + val feed = + logTime( + debugMessage = { "${this.javaClass.simpleName} FeedFilter returning ${it.size} objects" }, + block = ::feed, + ) return feed.take(limit()) } - open fun limit() = 1000 + open fun limit() = 500 /** Returns a string that serves as the key to invalidate the list if it changes. */ - abstract fun feedKey(): String + abstract fun feedKey(): Any open fun showHiddenKey(): Boolean = false abstract fun feed(): List } - -abstract class AdditiveFeedFilter : FeedFilter() { - abstract fun applyFilter(collection: Set): Set - - abstract fun sort(collection: Set): List - - open fun updateListWith( - oldList: List, - newItems: Set, - ): List { - checkNotInMainThread() - - val (feed, elapsed) = - measureTimedValue { - val newItemsToBeAdded = applyFilter(newItems) - if (newItemsToBeAdded.isNotEmpty()) { - val newList = oldList.toSet() + newItemsToBeAdded - sort(newList).take(limit()) - } else { - oldList - } - } - - // Log.d("Time", "${this.javaClass.simpleName} Additive Feed in $elapsed with ${feed.size} - // objects") - return feed - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt index 4f3d850b59..7ecbe13393 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt @@ -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,26 +20,23 @@ */ package com.vitorpamplona.amethyst.ui.dal -import com.vitorpamplona.amethyst.model.AROUND_ME -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS +import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address -import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNotes -import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHashes -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHashes -import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent -import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.utils.TimeUtils class FilterByListParams( - val isGlobal: Boolean, val isHiddenList: Boolean, - val isAroundMe: Boolean, - val followLists: Account.LiveFollowList?, - val hiddenLists: Account.LiveHiddenUsers, + val followLists: IFeedTopNavFilter?, + val hiddenLists: HiddenUsersState.LiveHiddenUsers, val now: Long = TimeUtils.oneMinuteFromNow(), ) { fun isNotHidden(userHex: String) = !(hiddenLists.hiddenUsers.contains(userHex) || hiddenLists.spammers.contains(userHex)) @@ -48,43 +45,39 @@ class FilterByListParams( fun isEventInList(noteEvent: Event): Boolean { if (followLists == null) return false - if (isAroundMe && followLists.geotags.isEmpty()) return false - return if (noteEvent is LiveActivitiesEvent) { - noteEvent.participantsIntersect(followLists.authors) || - noteEvent.isTaggedHashes(followLists.hashtags) || - noteEvent.isTaggedGeoHashes(followLists.geotags) || - noteEvent.isTaggedAddressableNotes(followLists.addresses) - } else if (noteEvent is CommentEvent) { - // ignore follows and checks only the root scope - noteEvent.isTaggedHashes(followLists.hashtags) || - noteEvent.isTaggedGeoHashes(followLists.geotags) || - noteEvent.isTaggedAddressableNotes(followLists.addresses) - } else { - noteEvent.pubKey in followLists.authors || - noteEvent.isTaggedHashes(followLists.hashtags) || - noteEvent.isTaggedGeoHashes(followLists.geotags) || - noteEvent.isTaggedAddressableNotes(followLists.addresses) - } + return followLists.match(noteEvent) + } + + fun isAuthorInFollows(author: HexKey): Boolean { + if (followLists == null) return false + + return followLists.matchAuthor(author) } fun isAuthorInFollows(address: Address): Boolean { if (followLists == null) return false - return address.pubKeyHex in followLists.authors + return followLists.matchAuthor(address.pubKeyHex) } + fun isGlobal(comingFrom: List) = + followLists is GlobalTopNavFilter && + comingFrom.any { followLists.outboxRelays.value.contains(it) } + fun match( noteEvent: Event, - isGlobalRelay: Boolean = true, - ) = ((isGlobal && isGlobalRelay) || isEventInList(noteEvent)) && + comingFrom: List = emptyList(), + ) = ((isGlobal(comingFrom)) || isEventInList(noteEvent)) && (isHiddenList || isNotHidden(noteEvent.pubKey)) && isNotInTheFuture(noteEvent) - fun match(address: Address?) = - address != null && - (isGlobal || isAuthorInFollows(address)) && - (isHiddenList || isNotHidden(address.pubKeyHex)) + fun match( + address: Address?, + comingFrom: List = emptyList(), + ) = address != null && + (isGlobal(comingFrom) || isAuthorInFollows(address)) && + (isHiddenList || isNotHidden(address.pubKeyHex)) companion object { fun showHiddenKey( @@ -93,15 +86,11 @@ class FilterByListParams( ) = selectedListName == PeopleListEvent.blockListFor(userHex) || selectedListName == MuteListEvent.blockListFor(userHex) fun create( - userHex: String, - selectedListName: String, - followLists: Account.LiveFollowList?, - hiddenUsers: Account.LiveHiddenUsers, + followLists: IFeedTopNavFilter?, + hiddenUsers: HiddenUsersState.LiveHiddenUsers, ): FilterByListParams = FilterByListParams( - isGlobal = selectedListName == GLOBAL_FOLLOWS, - isHiddenList = showHiddenKey(selectedListName, userHex), - isAroundMe = selectedListName == AROUND_ME, + isHiddenList = followLists is MutedAuthorsByOutboxTopNavFilter || followLists is MutedAuthorsByProxyTopNavFilter, followLists = followLists, hiddenLists = hiddenUsers, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt new file mode 100644 index 0000000000..fa561cbf6b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt @@ -0,0 +1,178 @@ +/** + * 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.ui.feeds + +import android.util.Log +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.amethyst.ui.dal.AdditiveComplexFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists +import com.vitorpamplona.ammolite.relays.BundledInsert +import com.vitorpamplona.ammolite.relays.BundledUpdate +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +@Stable +class ChannelFeedContentState( + val localFilter: AdditiveComplexFeedFilter, + val viewModelScope: CoroutineScope, +) : InvalidatableContent { + private val _feedContent = MutableStateFlow(ChannelFeedState.Loading) + val feedContent = _feedContent.asStateFlow() + + // Simple counter that changes when it needs to invalidate everything + private val _scrollToTop = MutableStateFlow(0) + val scrollToTop = _scrollToTop.asStateFlow() + var scrolltoTopPending = false + + private var lastFeedKey: Any? = null + + override val isRefreshing: MutableState = mutableStateOf(false) + + fun sendToTop() { + if (scrolltoTopPending) return + + scrolltoTopPending = true + viewModelScope.launch(Dispatchers.IO) { _scrollToTop.emit(_scrollToTop.value + 1) } + } + + suspend fun sentToTop() { + scrolltoTopPending = false + } + + private fun refresh() { + viewModelScope.launch(Dispatchers.Default) { refreshSuspended() } + } + + fun refreshSuspended() { + checkNotInMainThread() + + isRefreshing.value = true + try { + lastFeedKey = localFilter.feedKey() + val notes = localFilter.loadTop().distinctBy { it }.toImmutableList() + + val oldNotesState = _feedContent.value + if (oldNotesState is ChannelFeedState.Loaded) { + if (!equalImmutableLists(notes, oldNotesState.feed.value.list)) { + updateFeed(notes) + } + } else { + updateFeed(notes) + } + } finally { + isRefreshing.value = false + } + } + + private fun updateFeed(notes: ImmutableList) { + val currentState = _feedContent.value + if (notes.isEmpty()) { + _feedContent.tryEmit(ChannelFeedState.Empty) + } else if (currentState is ChannelFeedState.Loaded) { + currentState.feed.tryEmit(LoadedFeedState(notes, localFilter.showHiddenKey())) + } else { + _feedContent.tryEmit( + ChannelFeedState.Loaded(MutableStateFlow(LoadedFeedState(notes, localFilter.showHiddenKey()))), + ) + } + } + + fun refreshFromOldState(newItems: Set) { + val oldNotesState = _feedContent.value + if (oldNotesState is ChannelFeedState.Loaded) { + val oldList = oldNotesState.feed.value.list + + val newList = + localFilter + .updateListWith(oldList, newItems) + .distinctBy { it } + .toImmutableList() + if (!equalImmutableLists(newList, oldNotesState.feed.value.list)) { + updateFeed(newList) + } + } else if (oldNotesState is ChannelFeedState.Empty) { + val newList = + localFilter + .updateListWith(emptyList(), newItems) + .distinctBy { it } + .toImmutableList() + if (newList.isNotEmpty()) { + updateFeed(newList) + } + } else { + // Refresh Everything + refreshSuspended() + } + } + + private val bundler = BundledUpdate(250, Dispatchers.Default) + private val bundlerInsert = BundledInsert>(250, Dispatchers.Default) + + override fun invalidateData(ignoreIfDoing: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + bundler.invalidate(ignoreIfDoing) { + // adds the time to perform the refresh into this delay + // holding off new updates in case of heavy refresh routines. + refreshSuspended() + } + } + } + + fun checkKeysInvalidateDataAndSendToTop() { + if (lastFeedKey != localFilter.feedKey()) { + bundler.invalidate(false) { + // adds the time to perform the refresh into this delay + // holding off new updates in case of heavy refresh routines. + refreshSuspended() + sendToTop() + } + } + } + + fun invalidateInsertData(newItems: Set) { + bundlerInsert.invalidateList(newItems) { refreshFromOldState(it.flatten().toSet()) } + } + + fun updateFeedWith(newNotes: Set) { + if ((_feedContent.value is ChannelFeedState.Loaded || _feedContent.value is ChannelFeedState.Empty)) { + invalidateInsertData(newNotes) + } else { + // Refresh Everything + invalidateData() + } + } + + fun destroy() { + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + bundlerInsert.cancel() + bundler.cancel() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedState.kt new file mode 100644 index 0000000000..1c6fa31587 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedState.kt @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.feeds + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import kotlinx.coroutines.flow.MutableStateFlow + +@Stable +sealed class ChannelFeedState { + object Loading : ChannelFeedState() + + class Loaded( + val feed: MutableStateFlow>, + ) : ChannelFeedState() + + object Empty : ChannelFeedState() + + class FeedError( + val errorMessage: String, + ) : ChannelFeedState() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentState.kt index 2aab5999d1..b2410ce850 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentState.kt @@ -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 @@ -53,10 +53,12 @@ class FeedContentState( val scrollToTop = _scrollToTop.asStateFlow() var scrolltoTopPending = false - private var lastFeedKey: String? = null + private var lastFeedKey: Any? = null override val isRefreshing: MutableState = mutableStateOf(false) + val lastNoteCreatedAtWhenFullyLoaded = MutableStateFlow(null) + fun sendToTop() { if (scrolltoTopPending) return @@ -72,6 +74,17 @@ class FeedContentState( viewModelScope.launch(Dispatchers.Default) { refreshSuspended() } } + fun visibleNotes(): List { + val currentState = _feedContent.value + return if (currentState is FeedState.Loaded) { + currentState.feed.value.list + } else { + emptyList() + } + } + + fun lastNoteCreatedAtIfFilled() = lastNoteCreatedAtWhenFullyLoaded.value + fun refreshSuspended() { checkNotInMainThread() @@ -94,6 +107,13 @@ class FeedContentState( } private fun updateFeed(notes: ImmutableList) { + if (notes.size >= localFilter.limit()) { + val lastNomeTime = notes.lastOrNull { it.event != null }?.createdAt() + if (lastNomeTime != lastNoteCreatedAtWhenFullyLoaded.value) { + lastNoteCreatedAtWhenFullyLoaded.tryEmit(lastNomeTime) + } + } + val currentState = _feedContent.value if (notes.isEmpty()) { _feedContent.tryEmit(FeedState.Empty) @@ -106,15 +126,18 @@ class FeedContentState( } } + fun deleteFromFeed(deletedNotes: Set) { + val feed = _feedContent.value + if (feed is FeedState.Loaded) { + updateFeed((feed.feed.value.list - deletedNotes).toImmutableList()) + } + } + fun refreshFromOldState(newItems: Set) { val oldNotesState = _feedContent.value if (localFilter is AdditiveFeedFilter && lastFeedKey == localFilter.feedKey()) { if (oldNotesState is FeedState.Loaded) { - val deletionEvents: List = - newItems.mapNotNull { - val noteEvent = it.event - if (noteEvent is DeletionEvent) noteEvent else null - } + val deletionEvents: List = newItems.mapNotNull { it.event as? DeletionEvent } val oldList = if (deletionEvents.isEmpty()) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentStateView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentStateView.kt index bbdf94287c..31aa9f1b46 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentStateView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentStateView.kt @@ -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 @@ -29,7 +29,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedEmpty.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedEmpty.kt index 5e4349e41f..c446fcd692 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedEmpty.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedEmpty.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedError.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedError.kt index 596c94837b..f5e826cd65 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedError.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedError.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt index 44fc509eb2..eae3d24525 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt @@ -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 @@ -32,7 +32,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -56,7 +56,7 @@ fun FeedLoaded( state = listState, ) { itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item -> - Row(Modifier.fillMaxWidth().animateItemPlacement()) { + Row(Modifier.fillMaxWidth().animateItem()) { NoteCompose( item, modifier = Modifier.fillMaxWidth(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedState.kt index 4e46038ebd..c6e831f759 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedState.kt @@ -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,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.feeds +import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.model.Note import kotlinx.collections.immutable.ImmutableList @@ -40,7 +41,7 @@ sealed class FeedState { ) : FeedState() } -@Stable +@Immutable class LoadedFeedState( val list: ImmutableList, val showHidden: Boolean, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/InvalidatableContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/InvalidatableContent.kt index 8a0f4a610a..b4f4fab931 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/InvalidatableContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/InvalidatableContent.kt @@ -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,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.feeds -import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State interface InvalidatableContent { fun invalidateData(ignoreIfDoing: Boolean = false) - val isRefreshing: MutableState + val isRefreshing: State } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/LoadingFeed.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/LoadingFeed.kt index 006834c909..dc65971756 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/LoadingFeed.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/LoadingFeed.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RefresheableBox.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RefresheableBox.kt index bf7d22e9bf..a83c3c79fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RefresheableBox.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RefresheableBox.kt @@ -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,61 +21,71 @@ package com.vitorpamplona.amethyst.ui.feeds import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.pullrefresh.PullRefreshIndicator -import androidx.compose.material3.pullrefresh.pullRefresh -import androidx.compose.material3.pullrefresh.rememberPullRefreshState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +@OptIn(ExperimentalMaterial3Api::class) @Composable fun RefresheableBox( invalidateableContent: InvalidatableContent, enablePullRefresh: Boolean = true, - content: @Composable () -> Unit, + content: @Composable BoxScope.() -> Unit, ) { - RefresheableBox( - enablePullRefresh = enablePullRefresh, - onRefresh = { invalidateableContent.invalidateData() }, + var isRefreshing by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val onRefresh: () -> Unit = { + isRefreshing = true + scope.launch { + invalidateableContent.invalidateData() + delay(500) + isRefreshing = false + } + } + + if (enablePullRefresh) { + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = onRefresh, + modifier = Modifier.fillMaxSize(), + content = content, + ) + } else { + Box(Modifier.fillMaxSize(), content = content) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RefresheableBox( + onRefresh: () -> Unit, + content: @Composable BoxScope.() -> Unit, +) { + var isRefreshing by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val onRefresh: () -> Unit = { + isRefreshing = true + scope.launch { + onRefresh() + delay(500) + isRefreshing = false + } + } + + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = onRefresh, + modifier = Modifier.fillMaxSize(), content = content, ) } - -@Composable -fun RefresheableBox( - enablePullRefresh: Boolean = true, - onRefresh: () -> Unit, - content: @Composable () -> Unit, -) { - var refreshing by remember { mutableStateOf(false) } - val refresh = { - refreshing = true - onRefresh() - refreshing = false - } - val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) - - val modifier = - if (enablePullRefresh) { - Modifier.fillMaxSize().pullRefresh(pullRefreshState) - } else { - Modifier.fillMaxSize() - } - - Box(modifier) { - content() - - if (enablePullRefresh) { - PullRefreshIndicator( - refreshing = refreshing, - state = pullRefreshState, - modifier = Modifier.align(Alignment.TopCenter), - ) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index 187d6a1d4d..07efd2b0a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -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 @@ -41,10 +41,14 @@ object ScrollStateKeys { const val VIDEO_SCREEN = "VideoFeed" const val HOME_FOLLOWS = "HomeFollowsFeed" const val HOME_REPLIES = "HomeFollowsRepliesFeed" + const val MESSAGES_KNOWN = "MessagesKnown" + const val MESSAGES_NEW = "MessagesNew" const val PROFILE_GALLERY = "ProfileGalleryFeed" const val DRAFTS = "DraftsFeed" + const val DISCOVER_FOLLOWS = "DiscoverFollowSetsFeed" + const val DISCOVER_READS = "DiscoverReadsFeed" const val DISCOVER_CONTENT = "DiscoverDiscoverContentFeed" const val DISCOVER_MARKETPLACE = "DiscoverMarketplaceFeed" const val DISCOVER_LIVE = "DiscoverLiveFeed" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchLifecycleAndUpdateModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchLifecycleAndUpdateModel.kt new file mode 100644 index 0000000000..652d8e1131 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchLifecycleAndUpdateModel.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.feeds + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.compose.LifecycleResumeEffect + +@Composable +fun WatchLifecycleAndUpdateModel(model: InvalidatableContent) { + LaunchedEffect(model) { + model.invalidateData(true) + } + + LifecycleResumeEffect(model) { + model.invalidateData(true) + onPauseOrDispose {} + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt index 04b1a8d799..a35b9d88e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt index 804d7f5adc..204e28356d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt @@ -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 @@ -36,11 +36,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo +import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.NewItemsBubble import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -56,7 +56,7 @@ fun ChannelNamePreview() { ChatHeaderLayout( channelPicture = { Image( - painter = painterResource(R.drawable.github), + painter = painterRes(R.drawable.github, 1), contentDescription = stringRes(id = R.string.profile_banner), contentScale = ContentScale.FillWidth, ) @@ -89,7 +89,7 @@ fun ChannelNamePreview() { }, leadingContent = { Image( - painter = painterResource(R.drawable.github), + painter = painterRes(R.drawable.github, 2), contentDescription = stringRes(id = R.string.profile_banner), contentScale = ContentScale.FillWidth, modifier = Size55Modifier, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DisappearingScaffold.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DisappearingScaffold.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt index 1800e7c777..d9fd8f4135 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DisappearingScaffold.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt @@ -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,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn +package com.vitorpamplona.amethyst.ui.layouts import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentTransitionScope @@ -53,6 +53,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import com.vitorpamplona.amethyst.model.BooleanType +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness import kotlin.math.abs diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayout.kt index 2db9c2ed56..50a4a6c6f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayout.kt @@ -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 @@ -36,7 +36,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -45,6 +44,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.note.LikeIcon import com.vitorpamplona.amethyst.ui.note.TextCount import com.vitorpamplona.amethyst.ui.note.ZappedIcon +import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.QuoteBorder @@ -65,7 +65,7 @@ fun LeftPictureLayoutPreviewCard() { LeftPictureLayout( onImage = { Image( - painter = painterResource(R.drawable.github), + painter = painterRes(R.drawable.github, 3), contentDescription = stringRes(id = R.string.profile_banner), contentScale = ContentScale.FillWidth, modifier = Modifier.fillMaxSize().clip(QuoteBorder), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/RepostLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/RepostLayout.kt index bf570842d8..6a07390445 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/RepostLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/RepostLayout.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index af29b0bd5e..5e0e57d24e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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,12 +26,6 @@ import android.os.Parcelable import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.slideOutVertically import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -46,42 +40,63 @@ import androidx.core.util.Consumer import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen -import com.vitorpamplona.amethyst.ui.components.DisplayNotifyMessages +import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import com.vitorpamplona.amethyst.ui.navigation.navs.rememberNav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.getRouteWithArguments +import com.vitorpamplona.amethyst.ui.navigation.routes.isBaseRoute +import com.vitorpamplona.amethyst.ui.navigation.routes.isSameRoute +import com.vitorpamplona.amethyst.ui.note.nip22Comments.ReplyCommentPostScreen import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadRedirectScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.NewPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.BookmarkListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomByAuthorScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.NewGroupDMScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ChannelScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.EphemeralChatScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.metadata.NewEphemeralChatScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.metadata.ChannelMetadataScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivityChannelScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.CommunityScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.HomeScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.ListsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages.NewPublicMessageScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.ProfileScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.redirect.LoadRedirectScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.AllRelayListScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SecurityFiltersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog import com.vitorpamplona.amethyst.ui.uriToRoute +import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -120,29 +135,99 @@ fun AppNavigation( composable { SearchScreen(accountViewModel, nav) } composableFromEnd { SecurityFiltersScreen(accountViewModel, nav) } + composableFromEnd { PrivacyOptionsScreen(accountViewModel, nav) } composableFromEnd { BookmarkListScreen(accountViewModel, nav) } composableFromEnd { DraftListScreen(accountViewModel, nav) } composableFromEnd { SettingsScreen(sharedPreferencesViewModel, accountViewModel, nav) } + composableFromEnd { UserSettingsScreen(accountViewModel, nav) } composableFromBottomArgs { NIP47SetupScreen(accountViewModel, nav, it.nip47) } - composableFromEndArgs { AllRelayListScreen(it.toAdd, accountViewModel, nav) } + composableFromEndArgs { AllRelayListScreen(accountViewModel, nav) } + composableFromEndArgs { AllMediaServersScreen(accountViewModel, nav) } composableFromEndArgs { DvmContentDiscoveryScreen(it.id, accountViewModel, nav) } composableFromEndArgs { ProfileScreen(it.id, accountViewModel, nav) } composableFromEndArgs { ThreadScreen(it.id, accountViewModel, nav) } - composableFromEndArgs { HashtagScreen(it.id, accountViewModel, nav) } - composableFromEndArgs { GeoHashScreen(it.id, accountViewModel, nav) } - composableFromEndArgs { CommunityScreen(it.id, accountViewModel, nav) } - composableFromEndArgs { ChatroomScreen(it.id.toString(), it.message, it.replyId, it.draftId, accountViewModel, nav) } + composableFromEndArgs { HashtagScreen(it, accountViewModel, nav) } + composableFromEndArgs { GeoHashScreen(it, accountViewModel, nav) } + composableFromEndArgs { RelayInformationScreen(it.url, accountViewModel, nav) } + composableFromEndArgs { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + + composableFromEndArgs { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, accountViewModel, nav) } composableFromEndArgs { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) } - composableFromEndArgs { ChannelScreen(it.id, accountViewModel, nav) } + + composableFromEndArgs { PublicChatChannelScreen(it.id, accountViewModel, nav) } + composableFromEndArgs { LiveActivityChannelScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + composableFromEndArgs { + RelayUrlNormalizer.normalizeOrNull(it.relayUrl)?.let { relay -> + EphemeralChatScreen(RoomId(it.id, relay), accountViewModel, nav) + } + } composableFromBottomArgs { ChannelMetadataScreen(it.id, accountViewModel, nav) } + composableFromBottomArgs { NewEphemeralChatScreen(accountViewModel, nav) } composableFromBottomArgs { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) } composableArgs { LoadRedirectScreen(it.id, accountViewModel, nav) } + composableFromBottomArgs { + GeoHashPostScreen( + geohash = it.geohash, + message = it.message, + attachment = it.attachment?.ifBlank { null }?.toUri(), + reply = it.replyTo?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + accountViewModel, + nav, + ) + } + + composableFromBottomArgs { + NewPublicMessageScreen( + to = it.toKey(), + accountViewModel, + nav, + ) + } + + composableFromBottomArgs { + HashtagPostScreen( + hashtag = it.hashtag, + message = it.message, + attachment = it.attachment?.ifBlank { null }?.toUri(), + reply = it.replyTo?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + accountViewModel, + nav, + ) + } + + composableFromBottomArgs { + ReplyCommentPostScreen( + reply = it.replyTo?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + message = it.message, + attachment = it.attachment?.ifBlank { null }?.toUri(), + quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + accountViewModel, + nav, + ) + } + + composableFromBottomArgs { + NewProductScreen( + message = it.message, + attachment = it.attachment?.ifBlank { null }?.toUri(), + quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + accountViewModel, + nav, + ) + } + composableFromBottomArgs { - NewPostScreen( + ShortNotePostScreen( message = it.message, attachment = it.attachment?.ifBlank { null }?.toUri(), baseReplyTo = it.baseReplyTo?.let { hex -> accountViewModel.getNoteIfExists(hex) }, @@ -150,7 +235,6 @@ fun AppNavigation( fork = it.fork?.let { hex -> accountViewModel.getNoteIfExists(hex) }, version = it.version?.let { hex -> accountViewModel.getNoteIfExists(hex) }, draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - enableGeolocation = it.enableGeolocation, accountViewModel = accountViewModel, nav = nav, ) @@ -201,9 +285,6 @@ private fun NavigateIfIntentRequested( } nav.newStack(Route.NewPost(message = message, attachment = media.toString())) - - media = null - message = null } else { var newAccount by remember { mutableStateOf(null) } @@ -218,15 +299,15 @@ private fun NavigateIfIntentRequested( currentIntentNextPage?.let { intentNextPage -> var actionableNextPage by remember { - mutableStateOf(uriToRoute(intentNextPage)) + mutableStateOf(uriToRoute(intentNextPage, accountViewModel.account)) } LaunchedEffect(intentNextPage) { if (actionableNextPage != null) { actionableNextPage?.let { nextRoute -> val npub = runCatching { URI(intentNextPage.removePrefix("nostr:")).findParameterValue("account") }.getOrNull() - if (npub != null && accountStateViewModel.currentAccount() != npub) { - accountStateViewModel.switchUserSync(npub, nextRoute) + if (npub != null && accountStateViewModel.currentAccountNPub() != npub) { + accountStateViewModel.checkAndSwitchUserSync(npub, nextRoute) } else { val currentRoute = getRouteWithArguments(nav.controller) if (!isSameRoute(currentRoute, nextRoute)) { @@ -276,13 +357,13 @@ private fun NavigateIfIntentRequested( if (!uri.isNullOrBlank()) { // navigation functions - val newPage = uriToRoute(uri) + val newPage = uriToRoute(uri, accountViewModel.account) if (newPage != null) { scope.launch { val npub = runCatching { URI(uri.removePrefix("nostr:")).findParameterValue("account") }.getOrNull() - if (npub != null && accountStateViewModel.currentAccount() != npub) { - accountStateViewModel.switchUserSync(npub, newPage) + if (npub != null && accountStateViewModel.currentAccountNPub() != npub) { + accountStateViewModel.checkAndSwitchUserSync(npub, newPage) } else { val currentRoute = getRouteWithArguments(nav.controller) if (!isSameRoute(currentRoute, newPage)) { @@ -318,36 +399,6 @@ private fun NavigateIfIntentRequested( } } -private fun isSameRoute( - currentRoute: Route?, - newRoute: Route, -): Boolean { - if (currentRoute == null) return false - - if (currentRoute == newRoute) { - return true - } - - if (newRoute is Route.EventRedirect) { - return when (currentRoute) { - is Route.Note -> newRoute.id == currentRoute.id - is Route.Channel -> newRoute.id == currentRoute.id - else -> false - } - } - - return false -} - -val slideInVerticallyFromBottom = slideInVertically(animationSpec = tween(), initialOffsetY = { it }) -val slideOutVerticallyToBottom = slideOutVertically(animationSpec = tween(), targetOffsetY = { it }) - -val slideInHorizontallyFromEnd = slideInHorizontally(animationSpec = tween(), initialOffsetX = { it }) -val slideOutHorizontallyToEnd = slideOutHorizontally(animationSpec = tween(), targetOffsetX = { it }) - -val scaleIn = scaleIn(animationSpec = tween(), initialScale = 0.9f) -val scaleOut = scaleOut(animationSpec = tween(), targetScale = 0.9f) - fun URI.findParameterValue(parameterName: String): String? = rawQuery ?.split('&') diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/HeightDecreaser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/HeightDecreaser.kt deleted file mode 100644 index 18a2727b14..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/HeightDecreaser.kt +++ /dev/null @@ -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 com.vitorpamplona.amethyst.ui.navigation - -import androidx.compose.animation.core.AnimationSpec -import androidx.compose.animation.core.DecayAnimationSpec -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.TopAppBarScrollBehavior -import androidx.compose.material3.TopAppBarState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.platform.LocalDensity -import com.vitorpamplona.amethyst.ui.theme.TopBarSize - -// This is a hack to decrease the height for the -// default TopBar without having to reimplement it. - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun rememberHeightDecreaser(): TopAppBarScrollBehavior { - val height = - LocalDensity.current.run { - TopBarSize.toPx() - } - - return remember(height) { - HeightDecreaser(height) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -class HeightDecreaser( - height: Float, -) : TopAppBarScrollBehavior { - override val state: TopAppBarState = - TopAppBarState( - initialHeightOffsetLimit = -Float.MAX_VALUE, - initialHeightOffset = 0f, - initialContentOffset = 0f, - ).also { - it.heightOffset = height - } - - override val isPinned: Boolean = true - override val snapAnimationSpec: AnimationSpec? = null - override val flingAnimationSpec: DecayAnimationSpec? = null - override var nestedScrollConnection = - object : NestedScrollConnection { - override fun onPostScroll( - consumed: Offset, - available: Offset, - source: NestedScrollSource, - ): Offset = available - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/INav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/INav.kt deleted file mode 100644 index 26c37269b9..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/INav.kt +++ /dev/null @@ -1,204 +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.ui.navigation - -import android.annotation.SuppressLint -import androidx.compose.material3.DrawerState -import androidx.compose.material3.DrawerValue -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.navigation.NavHostController -import androidx.navigation.compose.rememberNavController -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlin.reflect.KClass - -@Composable -fun rememberNav(): Nav { - val navController = rememberNavController() - val scope = rememberCoroutineScope() - - return remember(navController, scope) { - Nav(navController, scope) - } -} - -@Composable -fun rememberExtendedNav( - nav: INav, - onClose: () -> Unit, -): INav = nav.onNavigate(onClose) - -@Stable -interface INav { - val drawerState: DrawerState - - fun nav(route: Route) - - fun nav(computeRoute: suspend () -> Route) - - fun newStack(route: Route) - - fun popBack() - - fun popUpTo( - route: Route, - klass: KClass, - ) - - fun closeDrawer() - - fun openDrawer() -} - -@Stable -class Nav( - val controller: NavHostController, - val scope: CoroutineScope, -) : INav { - override val drawerState = DrawerState(DrawerValue.Closed) - - override fun closeDrawer() { - scope.launch { drawerState.close() } - } - - override fun openDrawer() { - scope.launch { drawerState.open() } - } - - override fun nav(route: Route) { - scope.launch { - if (getRouteWithArguments(controller) != route) { - controller.navigate(route) - } - } - } - - override fun nav(computeRoute: suspend () -> Route) { - scope.launch { - val route = computeRoute() - if (getRouteWithArguments(controller) != route) { - controller.navigate(route) - } - } - } - - override fun newStack(route: Route) { - scope.launch { - controller.navigate(route) { - popUpTo(Route.Home) - launchSingleTop = true - } - } - } - - override fun popBack() { - scope.launch { - controller.navigateUp() - } - } - - @SuppressLint("RestrictedApi") - override fun popUpTo( - route: Route, - upToClass: KClass, - ) { - scope.launch { - controller.navigate(route) { - popUpTo(upToClass) { inclusive = true } - } - } - } -} - -@Stable -object EmptyNav : INav { - override val drawerState = DrawerState(DrawerValue.Closed) - - override fun closeDrawer() { - runBlocking { drawerState.close() } - } - - override fun openDrawer() { - runBlocking { drawerState.open() } - } - - override fun nav(route: Route) {} - - override fun nav(computeRoute: suspend () -> Route) {} - - override fun newStack(route: Route) {} - - override fun popBack() {} - - override fun popUpTo( - route: Route, - upToClass: KClass, - ) {} -} - -fun INav.onNavigate(runOnNavigate: () -> Unit): INav = ObservableNavigate(this, runOnNavigate) - -class ObservableNavigate( - val nav: INav, - val onNavigate: () -> Unit, -) : INav { - override val drawerState: DrawerState = nav.drawerState - - override fun closeDrawer() { - nav.closeDrawer() - } - - override fun openDrawer() { - nav.openDrawer() - } - - override fun nav(route: Route) { - onNavigate() - nav.nav(route) - } - - override fun nav(computeRoute: suspend () -> Route) { - onNavigate() - nav.nav(computeRoute) - } - - override fun newStack(route: Route) { - onNavigate() - nav.newStack(route) - } - - override fun popBack() { - onNavigate() - nav.popBack() - } - - override fun popUpTo( - route: Route, - upToClass: KClass, - ) { - onNavigate() - nav.popUpTo(route, upToClass) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavBuilderEffects.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt similarity index 75% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavBuilderEffects.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt index d3a3288339..caf2f71c64 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavBuilderEffects.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt @@ -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,6 +21,13 @@ package com.vitorpamplona.amethyst.ui.navigation import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.core.tween +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically import androidx.compose.runtime.Composable import androidx.navigation.NavBackStackEntry import androidx.navigation.NavGraphBuilder @@ -64,3 +71,12 @@ inline fun NavGraphBuilder.composableArgs(noinline content: @C content(it.toRoute()) } } + +val slideInVerticallyFromBottom = slideInVertically(animationSpec = tween(), initialOffsetY = { it }) +val slideOutVerticallyToBottom = slideOutVertically(animationSpec = tween(), targetOffsetY = { it }) + +val slideInHorizontallyFromEnd = slideInHorizontally(animationSpec = tween(), initialOffsetX = { it }) +val slideOutHorizontallyToEnd = slideOutHorizontally(animationSpec = tween(), targetOffsetX = { it }) + +val scaleIn = scaleIn(animationSpec = tween(), initialScale = 0.9f) +val scaleOut = scaleOut(animationSpec = tween(), targetScale = 0.9f) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt deleted file mode 100644 index ba81a7ba3b..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt +++ /dev/null @@ -1,163 +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.ui.navigation - -import com.vitorpamplona.amethyst.model.Channel -import com.vitorpamplona.amethyst.model.LocalCache.users -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource.user -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -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.nip17Dm.base.ChatroomKey -import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable -import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent -import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel -import com.vitorpamplona.quartz.nip37Drafts.DraftEvent -import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent -import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent -import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent -import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent - -fun routeFor( - note: Note, - loggedIn: User, -): Route? { - val noteEvent = note.event ?: return Route.Note(note.idHex) - - return routeFor(noteEvent, loggedIn) -} - -fun routeFor( - noteEvent: Event, - loggedIn: User, -): Route? { - if (noteEvent is DraftEvent) { - val innerEvent = noteEvent.preCachedDraft(loggedIn.pubkeyHex) - - if (innerEvent is IsInPublicChatChannel) { - innerEvent.channelId()?.let { - return Route.Channel(it) - } - } else if (innerEvent is LiveActivitiesEvent) { - innerEvent.aTag().toTag().let { - return Route.Channel(it) - } - } else if (innerEvent is LiveActivitiesChatMessageEvent) { - innerEvent.activity()?.toTag()?.let { - return Route.Channel(it) - } - } else if (innerEvent is ChatroomKeyable) { - val room = innerEvent.chatroomKey(loggedIn.pubkeyHex) - loggedIn.createChatroom(room) - return Route.Room(room.hashCode()) - } else if (innerEvent is AddressableEvent) { - return Route.Note(noteEvent.aTag().toTag()) - } else { - return Route.Note(noteEvent.id) - } - } else if (noteEvent is AppDefinitionEvent) { - return Route.ContentDiscovery(noteEvent.id) - } else if (noteEvent is IsInPublicChatChannel) { - noteEvent.channelId()?.let { - return Route.Channel(it) - } - } else if (noteEvent is ChannelCreateEvent) { - return Route.Channel(noteEvent.id) - } else if (noteEvent is LiveActivitiesEvent) { - noteEvent.aTag().toTag().let { - return Route.Channel(it) - } - } else if (noteEvent is LiveActivitiesChatMessageEvent) { - noteEvent.activity()?.toTag()?.let { - return Route.Channel(it) - } - } else if (noteEvent is ChatroomKeyable) { - val room = noteEvent.chatroomKey(loggedIn.pubkeyHex) - loggedIn.createChatroom(room) - return Route.Room(room.hashCode()) - } else if (noteEvent is CommunityDefinitionEvent) { - return Route.Community(noteEvent.aTag().toTag()) - } else if (noteEvent is AddressableEvent) { - return Route.Note(noteEvent.aTag().toTag()) - } else { - return Route.Note(noteEvent.id) - } - - return null -} - -fun routeToMessage( - user: HexKey, - draftMessage: String?, - replyId: HexKey? = null, - draftId: HexKey? = null, - accountViewModel: AccountViewModel, -): Route = - routeToMessage( - setOf(user), - draftMessage, - replyId, - draftId, - accountViewModel, - ) - -fun routeToMessage( - users: Set, - draftMessage: String?, - replyId: HexKey? = null, - draftId: HexKey? = null, - accountViewModel: AccountViewModel, -) = routeToMessage( - ChatroomKey(users), - draftMessage, - replyId, - draftId, - accountViewModel, -) - -fun routeToMessage( - room: ChatroomKey, - draftMessage: String?, - replyId: HexKey? = null, - draftId: HexKey? = null, - accountViewModel: AccountViewModel, -): Route { - accountViewModel.account.userProfile().createChatroom(room) - - return Route.Room(room.hashCode(), draftMessage, replyId, draftId) -} - -fun routeToMessage( - user: User, - draftMessage: String?, - replyId: HexKey? = null, - draftId: HexKey? = null, - accountViewModel: AccountViewModel, -): Route = routeToMessage(user.pubkeyHex, draftMessage, replyId, draftId, accountViewModel) - -fun routeFor(note: Channel): Route = Route.Channel(note.idHex) - -fun routeFor(user: User): Route.Profile = Route.Profile(user.pubkeyHex) - -fun authorRouteFor(note: Note): Route.Profile? = note.author?.pubkeyHex?.let { Route.Profile(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt similarity index 65% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt index 16533c528e..3ca3ad65dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt @@ -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,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.navigation +package com.vitorpamplona.amethyst.ui.navigation.bottombars -import android.graphics.Rect -import android.view.View -import android.view.ViewTreeObserver import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -30,7 +27,6 @@ import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.BottomAppBarDefaults.windowInsets import androidx.compose.material3.HorizontalDivider @@ -39,85 +35,19 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.State import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Size0dp import com.vitorpamplona.amethyst.ui.theme.Size10Modifier -import com.vitorpamplona.amethyst.ui.theme.Size24dp -import com.vitorpamplona.amethyst.ui.theme.Size25dp -import kotlinx.collections.immutable.persistentListOf - -val bottomNavigationItems = - persistentListOf( - BottomBarRoute(Route.Home, R.drawable.ic_home, R.string.route_home, Modifier.size(Size25dp), Modifier.size(Size24dp)), - BottomBarRoute(Route.Message, R.drawable.ic_dm, R.string.route_messages), - BottomBarRoute(Route.Video, R.drawable.ic_video, R.string.route_video), - BottomBarRoute(Route.Discover, R.drawable.ic_sensors, R.string.route_discover), - BottomBarRoute(Route.Notification, R.drawable.ic_notifications, R.string.route_notifications), - ) - -enum class Keyboard { - Opened, - Closed, -} - -fun isKeyboardOpen(view: View): Keyboard { - val rect = Rect() - view.getWindowVisibleDisplayFrame(rect) - val screenHeight = view.rootView.height - val keypadHeight = screenHeight - rect.bottom - - return if (keypadHeight > screenHeight * 0.15) { - Keyboard.Opened - } else { - Keyboard.Closed - } -} - -@Composable -fun keyboardAsState(): State { - val view = LocalView.current - - val keyboardState = remember(view) { mutableStateOf(isKeyboardOpen(view)) } - - DisposableEffect(view) { - val onGlobalListener = - ViewTreeObserver.OnGlobalLayoutListener { - val newKeyboardValue = isKeyboardOpen(view) - - if (newKeyboardValue != keyboardState.value) { - keyboardState.value = newKeyboardValue - } - } - view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener) - - onDispose { view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener) } - } - - return keyboardState -} - -@Composable -fun IfKeyboardClosed(inner: @Composable () -> Unit) { - val isKeyboardState by keyboardAsState() - if (isKeyboardState == Keyboard.Closed) { - inner() - } -} @Composable fun AppBottomBar( @@ -125,7 +55,10 @@ fun AppBottomBar( accountViewModel: AccountViewModel, nav: (Route) -> Unit, ) { - IfKeyboardClosed { RenderBottomMenu(selectedRoute, accountViewModel, nav) } + val isKeyboardState by keyboardAsState() + if (isKeyboardState == KeyboardState.Closed) { + RenderBottomMenu(selectedRoute, accountViewModel, nav) + } } @Composable @@ -185,7 +118,7 @@ private fun NotifiableIcon( ) { Box(route.notifSize) { Icon( - painter = painterResource(id = route.icon), + painter = painterRes(resourceId = route.icon, 0), contentDescription = stringRes(route.contentDescriptor), modifier = route.iconSize, tint = if (selected) MaterialTheme.colorScheme.primary else Color.Unspecified, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarRoutes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarRoutes.kt new file mode 100644 index 0000000000..16dc2c9905 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarRoutes.kt @@ -0,0 +1,48 @@ +/** + * 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.ui.navigation.bottombars + +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.Size23dp +import com.vitorpamplona.amethyst.ui.theme.Size24dp +import com.vitorpamplona.amethyst.ui.theme.Size25dp +import kotlinx.collections.immutable.persistentListOf + +class BottomBarRoute( + val route: Route, + val icon: Int, + val contentDescriptor: Int, + val notifSize: Modifier = Modifier.size(Size23dp), + val iconSize: Modifier = Modifier.size(Size20dp), +) + +val bottomNavigationItems = + persistentListOf( + BottomBarRoute(Route.Home, R.drawable.ic_home, R.string.route_home, Modifier.size(Size25dp), Modifier.size(Size24dp)), + BottomBarRoute(Route.Message, R.drawable.ic_dm, R.string.route_messages), + BottomBarRoute(Route.Video, R.drawable.ic_video, R.string.route_video), + BottomBarRoute(Route.Discover, R.drawable.ic_sensors, R.string.route_discover), + BottomBarRoute(Route.Notification, R.drawable.ic_notifications, R.string.route_notifications), + ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt new file mode 100644 index 0000000000..ce874a519e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.navigation.bottombars + +import android.graphics.Rect +import android.view.View +import android.view.ViewTreeObserver +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalView + +enum class KeyboardState { + Opened, + Closed, +} + +@Composable +fun keyboardAsState(): State { + val view = LocalView.current + + val keyboardState = remember(view) { mutableStateOf(isKeyboardOpen(view)) } + + DisposableEffect(view) { + val onGlobalListener = + ViewTreeObserver.OnGlobalLayoutListener { + val newKeyboardValue = isKeyboardOpen(view) + + if (newKeyboardValue != keyboardState.value) { + keyboardState.value = newKeyboardValue + } + } + view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener) + onDispose { view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener) } + } + + return keyboardState +} + +fun isKeyboardOpen(view: View): KeyboardState { + val rect = Rect() + view.getWindowVisibleDisplayFrame(rect) + val screenHeight = view.rootView.height + val keypadHeight = screenHeight - rect.bottom + + return if (keypadHeight > screenHeight * 0.15) { + KeyboardState.Opened + } else { + KeyboardState.Closed + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt similarity index 90% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt index b43c3ba471..ca1f0cb773 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt @@ -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,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.navigation +package com.vitorpamplona.amethyst.ui.navigation.drawer import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -32,7 +32,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Logout +import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material.icons.filled.RadioButtonChecked import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api @@ -45,7 +45,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -61,9 +60,11 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPicture import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage -import com.vitorpamplona.amethyst.ui.note.toShortenHex +import com.vitorpamplona.amethyst.ui.note.toShortDisplay import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog @@ -172,14 +173,10 @@ fun DisplayAccount( .width(55.dp) .padding(0.dp), ) { - AccountPicture( - it, - accountViewModel.settings.showProfilePictures.value, - loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, - ) + AccountPicture(it, accountViewModel) } Spacer(modifier = Modifier.width(16.dp)) - Column(modifier = Modifier.weight(1f)) { AccountName(acc, it) } + Column(modifier = Modifier.weight(1f)) { AccountName(acc, it, accountViewModel) } Column(modifier = Modifier.width(32.dp)) { ActiveMarker(acc, accountViewModel) } } } @@ -211,18 +208,17 @@ private fun ActiveMarker( @Composable private fun AccountPicture( user: User, - loadProfilePicture: Boolean, - loadRobohash: Boolean, + accountViewModel: AccountViewModel, ) { - val profilePicture by user.live().profilePictureChanges.observeAsState() + val profilePicture by observeUserPicture(user, accountViewModel) RobohashFallbackAsyncImage( robot = user.pubkeyHex, model = profilePicture, contentDescription = stringRes(R.string.profile_image), modifier = AccountPictureModifier, - loadProfilePicture = loadProfilePicture, - loadRobohash = loadRobohash, + loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, ) } @@ -230,8 +226,9 @@ private fun AccountPicture( private fun AccountName( acc: AccountInfo, user: User, + accountViewModel: AccountViewModel, ) { - val info by user.live().userMetadataInfo.observeAsState() + val info by observeUserInfo(user, accountViewModel) info?.let { it.bestName()?.let { name -> @@ -245,7 +242,7 @@ private fun AccountName( } Text( - text = remember(user) { acc.npub.toShortenHex() }, + text = remember(user) { acc.npub.toShortDisplay() }, ) } @@ -284,7 +281,7 @@ private fun LogoutButton( onClick = { logoutDialog = true }, ) { Icon( - imageVector = Icons.Default.Logout, + imageVector = Icons.AutoMirrored.Filled.Logout, contentDescription = stringRes(R.string.log_out), tint = MaterialTheme.colorScheme.onSurface, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt similarity index 84% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index 1cad79dc54..cf1c161b15 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -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,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.navigation +package com.vitorpamplona.amethyst.ui.navigation.drawer import androidx.compose.foundation.Image import androidx.compose.foundation.border @@ -44,9 +44,16 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.outlined.BookmarkBorder import androidx.compose.material.icons.outlined.CloudUpload +import androidx.compose.material.icons.outlined.Drafts import androidx.compose.material.icons.outlined.GroupAdd +import androidx.compose.material.icons.outlined.Key +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.outlined.Security +import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -58,10 +65,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -69,9 +74,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLinkStyles @@ -90,12 +93,18 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.actions.mediaServers.MediaServersListView +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserFollowerCount +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.LoadStatuses -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog +import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup.AccountBackupDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.ShowQRDialog import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -107,13 +116,13 @@ import com.vitorpamplona.amethyst.ui.theme.Size16dp import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size22Modifier import com.vitorpamplona.amethyst.ui.theme.Size26Modifier +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.bannerModifier import com.vitorpamplona.amethyst.ui.theme.drawerSpacing import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.profileContentHeaderModifier -import com.vitorpamplona.amethyst.ui.tor.ConnectTorDialog -import com.vitorpamplona.ammolite.relays.RelayPoolStatus import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayPool import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists @@ -149,13 +158,15 @@ fun DrawerContent( EditStatusBoxes(accountViewModel.account.userProfile(), accountViewModel, nav) } - FollowingAndFollowerCounts(accountViewModel.account, onClickUser) + FollowingAndFollowerCounts(accountViewModel.account, accountViewModel, onClickUser) HorizontalDivider( thickness = DividerThickness, modifier = Modifier.padding(top = 20.dp), ) + Spacer(modifier = StdHorzSpacer) + ListContent( modifier = Modifier.fillMaxWidth(), openSheet, @@ -181,7 +192,7 @@ fun ProfileContent( accountViewModel: AccountViewModel, onClickUser: () -> Unit, ) { - val userInfo by baseAccountUser.live().userMetadataInfo.observeAsState() + val userInfo by observeUserInfo(baseAccountUser, accountViewModel) ProfileContentTemplate( profilePubHex = baseAccountUser.pubkeyHex, @@ -216,7 +227,7 @@ fun ProfileContentTemplate( ) } else { Image( - painter = painterResource(R.drawable.profile_banner), + painter = painterRes(R.drawable.profile_banner, 3), contentDescription = stringRes(R.string.profile_banner), contentScale = ContentScale.FillWidth, modifier = bannerModifier, @@ -268,9 +279,9 @@ private fun EditStatusBoxes( StatusEditBar(accountViewModel = accountViewModel, nav = nav) } else { statuses.forEach { - val originalStatus by it.live().content.observeAsState() + val noteStatus by observeNote(it, accountViewModel) - StatusEditBar(originalStatus, it.address, accountViewModel, nav) + StatusEditBar(noteStatus.note.event?.content, it.address, accountViewModel, nav) } } } @@ -377,20 +388,15 @@ fun UserStatusDeleteButton(onClick: () -> Unit) { @Composable private fun FollowingAndFollowerCounts( baseAccountUser: Account, + accountViewModel: AccountViewModel, onClick: () -> Unit, ) { - val followingCount = baseAccountUser.liveKind3Follows.collectAsStateWithLifecycle() - var followerCount by remember { mutableStateOf("--") } - - WatchFollower(baseAccountUser = baseAccountUser) { newFollower -> - if (followerCount != newFollower) { - followerCount = newFollower - } - } - Row( modifier = drawerSpacing.clickable(onClick = onClick), ) { + val followingCount = baseAccountUser.kind3FollowList.flow.collectAsStateWithLifecycle() + val followerCount by observeUserFollowerCount(baseAccountUser.userProfile(), accountViewModel) + Text( text = followingCount.value.authors.size @@ -403,7 +409,7 @@ private fun FollowingAndFollowerCounts( Spacer(modifier = DoubleHorzSpacer) Text( - text = followerCount, + text = if (followerCount > 0) followerCount.toString() else "--", fontWeight = FontWeight.Bold, ) @@ -411,22 +417,6 @@ private fun FollowingAndFollowerCounts( } } -@Composable -fun WatchFollower( - baseAccountUser: Account, - onReady: (String) -> Unit, -) { - val accountUserFollowersState by baseAccountUser - .userProfile() - .live() - .followers - .observeAsState() - - LaunchedEffect(key1 = accountUserFollowersState) { - onReady(baseAccountUser.followerCount().toString()) - } -} - @Composable fun ListContent( modifier: Modifier, @@ -434,18 +424,12 @@ fun ListContent( accountViewModel: AccountViewModel, nav: INav, ) { - var editMediaServers by remember { mutableStateOf(false) } - var backupDialogOpen by remember { mutableStateOf(false) } - var conectOrbotDialogOpen by remember { mutableStateOf(false) } - - val context = LocalContext.current - Column(modifier) { NavigationRow( title = R.string.profile, - icon = R.drawable.ic_profile, + icon = Icons.Default.AccountCircle, tint = MaterialTheme.colorScheme.primary, nav = nav, route = remember { Route.Profile(accountViewModel.userProfile().pubkeyHex) }, @@ -461,7 +445,7 @@ fun ListContent( NavigationRow( title = R.string.bookmarks, - icon = R.drawable.ic_bookmarks, + icon = Icons.Outlined.BookmarkBorder, tint = MaterialTheme.colorScheme.onBackground, nav = nav, route = Route.Bookmarks, @@ -469,7 +453,7 @@ fun ListContent( NavigationRow( title = R.string.drafts, - icon = R.drawable.ic_topics, + icon = Icons.Outlined.Drafts, tint = MaterialTheme.colorScheme.onBackground, nav = nav, route = Route.Drafts, @@ -479,7 +463,7 @@ fun ListContent( accountViewModel = accountViewModel, onClick = { nav.closeDrawer() - nav.nav(Route.EditRelays()) + nav.nav(Route.EditRelays) }, ) @@ -489,32 +473,31 @@ fun ListContent( tint = MaterialTheme.colorScheme.onBackground, onClick = { nav.closeDrawer() - editMediaServers = true + nav.nav(Route.EditMediaServers) }, ) NavigationRow( title = R.string.security_filters, - icon = R.drawable.ic_security, + icon = Icons.Outlined.Security, tint = MaterialTheme.colorScheme.onBackground, nav = nav, route = Route.SecurityFilters, ) - IconRow( + NavigationRow( title = R.string.privacy_options, icon = R.drawable.ic_tor, + iconReference = 1, tint = MaterialTheme.colorScheme.onBackground, - onClick = { - nav.closeDrawer() - conectOrbotDialogOpen = true - }, + nav = nav, + route = Route.PrivacyOptions, ) accountViewModel.account.settings.keyPair.privKey?.let { IconRow( title = R.string.backup_keys, - icon = R.drawable.ic_key, + icon = Icons.Outlined.Key, tint = MaterialTheme.colorScheme.onBackground, onClick = { nav.closeDrawer() @@ -525,12 +508,20 @@ fun ListContent( NavigationRow( title = R.string.preferences, - icon = R.drawable.ic_settings, + icon = Icons.Outlined.Settings, tint = MaterialTheme.colorScheme.onBackground, nav = nav, route = Route.Settings, ) + NavigationRow( + title = R.string.user_preferences, + icons = listOf(Icons.Outlined.Person, Icons.Outlined.Settings), + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + route = Route.UserSettings, + ) + Spacer(modifier = Modifier.weight(1f)) IconRow( @@ -541,43 +532,22 @@ fun ListContent( ) } - if (editMediaServers) { - MediaServersListView({ editMediaServers = false }, accountViewModel = accountViewModel, nav = nav) - } if (backupDialogOpen) { AccountBackupDialog(accountViewModel, onClose = { backupDialogOpen = false }) } - if (conectOrbotDialogOpen) { - ConnectTorDialog( - torSettings = - accountViewModel.account.settings.torSettings - .toSettings(), - onClose = { conectOrbotDialogOpen = false }, - onPost = { torSettings -> - conectOrbotDialogOpen = false - accountViewModel.setTorSettings(torSettings) - }, - onError = { - accountViewModel.toastManager.toast( - stringRes(context, R.string.could_not_connect_to_tor), - it, - ) - }, - ) - } } @Composable private fun RelayStatus(accountViewModel: AccountViewModel) { - val connectedRelaysText by accountViewModel.relayStatusFlow().collectAsStateWithLifecycle(RelayPoolStatus(0, 0)) + val connectedRelaysText by accountViewModel.relayStatusFlow().collectAsStateWithLifecycle() RenderRelayStatus(connectedRelaysText) } @Composable -private fun RenderRelayStatus(relayPool: RelayPoolStatus) { +private fun RenderRelayStatus(relayPool: RelayPool.RelayPoolStatus) { val text by - remember(relayPool) { derivedStateOf { "${relayPool.connected}/${relayPool.available}" } } + remember(relayPool) { derivedStateOf { "${relayPool.connected.size}/${relayPool.available.size}" } } val placeHolder = MaterialTheme.colorScheme.placeholderText @@ -603,6 +573,7 @@ private fun RenderRelayStatus(relayPool: RelayPoolStatus) { fun NavigationRow( title: Int, icon: Int, + iconReference: Int, tint: Color, nav: INav, route: Route, @@ -610,6 +581,7 @@ fun NavigationRow( IconRow( title, icon, + iconReference, tint, onClick = { nav.closeDrawer() @@ -618,10 +590,47 @@ fun NavigationRow( ) } +@Composable +fun NavigationRow( + title: Int, + icon: ImageVector, + tint: Color, + nav: INav, + route: Route, +) { + NavigationRow( + title = title, + icons = listOf(icon), + tint = tint, + nav = nav, + route = route, + ) +} + +@Composable +fun NavigationRow( + title: Int, + icons: List, + tint: Color, + nav: INav, + route: Route, +) { + IconRow( + title = title, + icons = icons, + tint = tint, + onClick = { + nav.closeDrawer() + nav.nav(route) + }, + ) +} + @Composable fun IconRow( title: Int, icon: Int, + iconReference: Int, tint: Color, onClick: () -> Unit, ) { @@ -638,7 +647,7 @@ fun IconRow( verticalAlignment = Alignment.CenterVertically, ) { Icon( - painter = painterResource(icon), + painter = painterRes(icon, iconReference), contentDescription = stringRes(title), modifier = Size22Modifier, tint = tint, @@ -658,6 +667,21 @@ fun IconRow( icon: ImageVector, tint: Color, onClick: () -> Unit, +) { + IconRow( + title = title, + icons = listOf(icon), + tint = tint, + onClick = onClick, + ) +} + +@Composable +fun IconRow( + title: Int, + icons: List, + tint: Color, + onClick: () -> Unit, ) { Row( modifier = @@ -672,12 +696,15 @@ fun IconRow( modifier = IconRowModifier, verticalAlignment = Alignment.CenterVertically, ) { - Icon( - imageVector = icon, - contentDescription = stringRes(title), - modifier = Size22Modifier, - tint = tint, - ) + icons.forEach { icon -> + Icon( + imageVector = icon, + contentDescription = stringRes(title), + modifier = Size22Modifier.padding(end = 4.dp), + tint = tint, + ) + } + Text( modifier = IconRowTextModifier, text = stringRes(title), @@ -706,8 +733,8 @@ fun IconRowRelays( verticalAlignment = Alignment.CenterVertically, ) { Icon( - painter = painterResource(R.drawable.relays), - null, + painter = painterRes(R.drawable.relays, 4), + contentDescription = stringRes(R.string.relay_setup), modifier = Modifier.size(22.dp), tint = MaterialTheme.colorScheme.onSurface, ) @@ -731,8 +758,6 @@ fun BottomContent( accountViewModel: AccountViewModel, nav: INav, ) { - val coroutineScope = rememberCoroutineScope() - // store the dialog open or close state var dialogOpen by remember { mutableStateOf(false) } @@ -765,7 +790,7 @@ fun BottomContent( nav.closeDrawer() }, ) { - append("v12" + BuildConfig.VERSION_NAME + "-" + BuildConfig.FLAVOR.uppercase()) + append("v" + BuildConfig.VERSION_NAME + "-" + BuildConfig.FLAVOR.uppercase()) } } } @@ -784,7 +809,7 @@ fun BottomContent( }, ) { Icon( - painter = painterResource(R.drawable.ic_qrcode), + painter = painterRes(R.drawable.ic_qrcode, 2), contentDescription = stringRes(id = R.string.show_npub_as_a_qr_code), modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.primary, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/EmptyNav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/EmptyNav.kt new file mode 100644 index 0000000000..8e3324ae6b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/EmptyNav.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.navigation.navs + +import androidx.compose.material3.DrawerState +import androidx.compose.material3.DrawerValue +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.runBlocking +import kotlin.reflect.KClass + +@Stable +object EmptyNav : INav { + override val scope: CoroutineScope get() = TODO("Not yet implemented") + override val drawerState = DrawerState(DrawerValue.Closed) + + override fun closeDrawer() = runBlocking { drawerState.close() } + + override fun openDrawer() = runBlocking { drawerState.open() } + + override fun nav(route: Route) {} + + override fun nav(computeRoute: suspend () -> Route?) {} + + override fun newStack(route: Route) {} + + override fun popBack() {} + + override fun popUpTo( + route: Route, + klass: KClass, + ) {} +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/UrlId.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/INav.kt similarity index 62% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/UrlId.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/INav.kt index 404288d1d0..aefcb060cb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/UrlId.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/INav.kt @@ -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,24 +18,33 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip73ExternalIds +package com.vitorpamplona.amethyst.ui.navigation.navs -import com.vitorpamplona.quartz.utils.toStringNoFragment -import org.czeal.rfc3986.URIReference +import androidx.compose.material3.DrawerState +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import kotlinx.coroutines.CoroutineScope +import kotlin.reflect.KClass -class UrlId( - val url: String, - val hint: String? = null, -) : ExternalId { - override fun toScope() = toScope(url) +@Stable +interface INav { + val scope: CoroutineScope + val drawerState: DrawerState - override fun toKind() = toKind(url) + fun closeDrawer() - override fun hint() = hint + fun openDrawer() - companion object { - fun toScope(url: String) = URIReference.parse(url).normalize().toStringNoFragment() + fun nav(route: Route) - fun toKind(url: String) = "web" - } + fun nav(computeRoute: suspend () -> Route?) + + fun newStack(route: Route) + + fun popBack() + + fun popUpTo( + route: Route, + klass: KClass, + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt new file mode 100644 index 0000000000..fa3b50e994 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt @@ -0,0 +1,94 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.navigation.navs + +import android.annotation.SuppressLint +import androidx.compose.material3.DrawerState +import androidx.compose.material3.DrawerValue +import androidx.compose.runtime.Stable +import androidx.navigation.NavHostController +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.getRouteWithArguments +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlin.reflect.KClass + +@Stable +class Nav( + val controller: NavHostController, + override val scope: CoroutineScope, +) : INav { + override val drawerState = DrawerState(DrawerValue.Closed) + + override fun closeDrawer() { + scope.launch { drawerState.close() } + } + + override fun openDrawer() { + scope.launch { drawerState.open() } + } + + override fun nav(route: Route) { + scope.launch { + if (getRouteWithArguments(controller) != route) { + controller.navigate(route) + } + } + } + + override fun nav(computeRoute: suspend () -> Route?) { + scope.launch { + val route = computeRoute() + if (route != null && getRouteWithArguments(controller) != route) { + controller.navigate(route) + } + } + } + + override fun newStack(route: Route) { + scope.launch { + controller.navigate(route) { + popUpTo(route) { + inclusive = true + } + launchSingleTop = true + } + } + } + + override fun popBack() { + scope.launch { + controller.navigateUp() + } + } + + @SuppressLint("RestrictedApi") + override fun popUpTo( + route: Route, + klass: KClass, + ) { + scope.launch { + controller.navigate(route) { + popUpTo(klass) { inclusive = true } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/ObservableNav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/ObservableNav.kt new file mode 100644 index 0000000000..c1ecb01995 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/ObservableNav.kt @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.navigation.navs + +import androidx.compose.material3.DrawerState +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlin.reflect.KClass + +class ObservableNav( + val sourceNav: INav, + override val scope: CoroutineScope, + val onBeforeNavigate: () -> Unit, +) : INav { + override val drawerState: DrawerState = sourceNav.drawerState + + override fun closeDrawer() { + sourceNav.closeDrawer() + } + + override fun openDrawer() { + sourceNav.openDrawer() + } + + override fun nav(route: Route) { + scope.launch { + onBeforeNavigate() + } + sourceNav.nav(route) + } + + override fun nav(computeRoute: suspend () -> Route?) { + scope.launch { + onBeforeNavigate() + } + sourceNav.nav(computeRoute) + } + + override fun newStack(route: Route) { + scope.launch { + onBeforeNavigate() + } + sourceNav.newStack(route) + } + + override fun popBack() { + scope.launch { + onBeforeNavigate() + } + sourceNav.popBack() + } + + override fun popUpTo( + route: Route, + upToClass: KClass, + ) { + scope.launch { + onBeforeNavigate() + } + sourceNav.popUpTo(route, upToClass) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/RememberNavs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/RememberNavs.kt new file mode 100644 index 0000000000..218f5ae927 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/RememberNavs.kt @@ -0,0 +1,47 @@ +/** + * 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.ui.navigation.navs + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.navigation.compose.rememberNavController + +@Composable +fun rememberNav(): Nav { + val navController = rememberNavController() + val scope = rememberCoroutineScope() + + return remember(navController, scope) { + Nav(navController, scope) + } +} + +@Composable +fun rememberExtendedNav( + nav: INav, + onBeforeNavigate: () -> Unit, +): INav { + val scope = rememberCoroutineScope() + return remember(nav, scope) { + ObservableNav(nav, scope, onBeforeNavigate) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt new file mode 100644 index 0000000000..a51df64409 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt @@ -0,0 +1,234 @@ +/** + * 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.ui.navigation.routes + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId +import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent +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.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel +import com.vitorpamplona.quartz.nip37Drafts.DraftEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip73ExternalIds.location.isGeohashedScoped +import com.vitorpamplona.quartz.nip73ExternalIds.topics.isHashtagScoped +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent + +fun routeFor( + note: Note, + loggedIn: Account, +): Route? { + val noteEvent = note.event ?: return Route.EventRedirect(note.idHex) + + return routeFor(noteEvent, loggedIn) +} + +fun routeFor( + noteEvent: Event, + loggedIn: Account, +): Route? { + if (noteEvent is DraftEvent) { + val innerEvent = loggedIn.draftsDecryptionCache.preCachedDraft(noteEvent) + + if (innerEvent is IsInPublicChatChannel) { + innerEvent.channelId()?.let { + return Route.PublicChatChannel(it) + } + } else if (innerEvent is LiveActivitiesEvent) { + innerEvent.address().let { + return Route.LiveActivityChannel(it.kind, it.pubKeyHex, it.dTag) + } + } else if (innerEvent is LiveActivitiesChatMessageEvent) { + innerEvent.activityAddress()?.let { + return Route.LiveActivityChannel(it.kind, it.pubKeyHex, it.dTag) + } + } else if (innerEvent is ChatroomKeyable) { + val room = innerEvent.chatroomKey(loggedIn.userProfile().pubkeyHex) + loggedIn.chatroomList.getOrCreatePrivateChatroom(room) + return Route.Room(room) + } else if (innerEvent is AddressableEvent) { + return Route.Note(noteEvent.aTag().toTag()) + } else { + return Route.Note(noteEvent.id) + } + } else if (noteEvent is AppDefinitionEvent) { + return Route.ContentDiscovery(noteEvent.id) + } else if (noteEvent is IsInPublicChatChannel) { + noteEvent.channelId()?.let { + return Route.PublicChatChannel(it) + } + } else if (noteEvent is ChannelCreateEvent) { + return Route.PublicChatChannel(noteEvent.id) + } else if (noteEvent is LiveActivitiesEvent) { + noteEvent.address().let { + return Route.LiveActivityChannel(it.kind, it.pubKeyHex, it.dTag) + } + } else if (noteEvent is LiveActivitiesChatMessageEvent) { + noteEvent.activityAddress()?.let { + return Route.LiveActivityChannel(it.kind, it.pubKeyHex, it.dTag) + } + } else if (noteEvent is ChatroomKeyable) { + val room = noteEvent.chatroomKey(loggedIn.userProfile().pubkeyHex) + loggedIn.chatroomList.getOrCreatePrivateChatroom(room) + return Route.Room(room) + } else if (noteEvent is CommunityDefinitionEvent) { + return Route.Community(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag()) + } else if (noteEvent is GiftWrapEvent) { + noteEvent.innerEventId?.let { + return routeFor(LocalCache.getOrCreateNote(it), loggedIn) + } + } else if (noteEvent is SealedRumorEvent) { + noteEvent.innerEventId?.let { + return routeFor(LocalCache.getOrCreateNote(it), loggedIn) + } + } else if (noteEvent is AddressableEvent) { + return Route.Note(noteEvent.aTag().toTag()) + } else { + return Route.Note(noteEvent.id) + } + + return null +} + +fun routeToMessage( + user: HexKey, + draftMessage: String?, + replyId: HexKey? = null, + draftId: HexKey? = null, + accountViewModel: AccountViewModel, +): Route = + routeToMessage( + setOf(user), + draftMessage, + replyId, + draftId, + accountViewModel, + ) + +fun routeToMessage( + users: Set, + draftMessage: String?, + replyId: HexKey? = null, + draftId: HexKey? = null, + accountViewModel: AccountViewModel, +) = routeToMessage( + ChatroomKey(users), + draftMessage, + replyId, + draftId, + accountViewModel, +) + +fun routeToMessage( + room: ChatroomKey, + draftMessage: String?, + replyId: HexKey? = null, + draftId: HexKey? = null, + accountViewModel: AccountViewModel, +): Route = routeToMessage(room, draftMessage, replyId, draftId, accountViewModel.account) + +fun routeToMessage( + room: ChatroomKey, + draftMessage: String? = null, + replyId: HexKey? = null, + draftId: HexKey? = null, + account: Account, +): Route { + account.chatroomList.getOrCreatePrivateChatroom(room) + + return Route.Room(room, draftMessage, replyId, draftId) +} + +fun routeToMessage( + user: User, + draftMessage: String?, + replyId: HexKey? = null, + draftId: HexKey? = null, + accountViewModel: AccountViewModel, +): Route = routeToMessage(user.pubkeyHex, draftMessage, replyId, draftId, accountViewModel) + +fun routeFor(note: EphemeralChatChannel): Route = Route.EphemeralChat(note.roomId.id, note.roomId.relayUrl.url) + +fun routeFor(note: PublicChatChannel): Route = Route.PublicChatChannel(note.idHex) + +fun routeFor(note: LiveActivitiesChannel): Route = Route.LiveActivityChannel(note.address.kind, note.address.pubKeyHex, note.address.dTag) + +fun routeFor(roomId: RoomId): Route = Route.EphemeralChat(roomId.id, roomId.relayUrl.url) + +fun routeFor(user: User): Route.Profile = Route.Profile(user.pubkeyHex) + +fun authorRouteFor(note: Note): Route.Profile? = note.author?.pubkeyHex?.let { Route.Profile(it) } + +fun routeReplyTo( + note: Note, + account: Account, +): Route? { + val noteEvent = note.event + return when (noteEvent) { + is PublicMessageEvent -> Route.NewPublicMessage(noteEvent.groupKeySet() - account.userProfile().pubkeyHex) + is TextNoteEvent -> Route.NewPost(baseReplyTo = note.idHex) + is PrivateDmEvent -> + routeToMessage( + room = noteEvent.chatroomKey(account.userProfile().pubkeyHex), + draftMessage = null, + replyId = noteEvent.id, + draftId = null, + account = account, + ) + is ChatroomKeyable -> + routeToMessage( + room = noteEvent.chatroomKey(account.userProfile().pubkeyHex), + draftMessage = null, + replyId = noteEvent.id, + draftId = null, + account = account, + ) + is CommentEvent -> { + if (noteEvent.isGeohashedScoped()) { + Route.GeoPost(replyTo = note.idHex) + } else if (noteEvent.isHashtagScoped()) { + Route.HashtagPost(replyTo = note.idHex) + } else { + Route.GenericCommentPost(replyTo = note.idHex) + } + } + + else -> Route.GenericCommentPost(replyTo = note.idHex) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt similarity index 59% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 3f18e82602..e21008d561 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -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,27 +18,14 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.navigation +package com.vitorpamplona.amethyst.ui.navigation.routes -import androidx.compose.foundation.layout.size -import androidx.compose.ui.Modifier import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavHostController import androidx.navigation.toRoute -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.theme.Size20dp -import com.vitorpamplona.amethyst.ui.theme.Size23dp import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import kotlinx.serialization.Serializable -import kotlin.String - -class BottomBarRoute( - val route: Route, - val icon: Int, - val contentDescriptor: Int = R.string.route, - val notifSize: Modifier = Modifier.size(Size23dp), - val iconSize: Modifier = Modifier.size(Size20dp), -) sealed class Route { @Serializable object Home : Route() @@ -55,12 +42,16 @@ sealed class Route { @Serializable object SecurityFilters : Route() + @Serializable object PrivacyOptions : Route() + @Serializable object Bookmarks : Route() @Serializable object Drafts : Route() @Serializable object Settings : Route() + @Serializable object UserSettings : Route() + object Lists : Route( route = "Lists", @@ -70,9 +61,9 @@ sealed class Route { @Serializable object EditProfile : Route() - @Serializable data class EditRelays( - val toAdd: String? = null, - ) : Route() + @Serializable object EditRelays : Route() + + @Serializable object EditMediaServers : Route() @Serializable data class Nip47NWCSetup( val nip47: String? = null, @@ -91,21 +82,44 @@ sealed class Route { ) : Route() @Serializable data class Hashtag( - val id: String, + val hashtag: String, ) : Route() @Serializable data class Geohash( - val id: String, + val geohash: String, ) : Route() @Serializable data class Community( + val kind: Int, + val pubKeyHex: HexKey, + val dTag: String, + ) : Route() + + @Serializable data class PublicChatChannel( val id: String, ) : Route() - @Serializable data class Channel( + @Serializable data class LiveActivityChannel( + val kind: Int, + val pubKeyHex: HexKey, + val dTag: String, + ) : Route() + + @Serializable data class EphemeralChatChannel( val id: String, ) : Route() + @Serializable data class RelayInfo( + val url: String, + ) : Route() + + @Serializable data class EphemeralChat( + val id: String, + val relayUrl: String, + ) : Route() + + @Serializable object NewEphemeralChat : Route() + @Serializable data class ChannelMetadataEdit( val id: String? = null, ) : Route() @@ -116,11 +130,30 @@ sealed class Route { ) : Route() @Serializable data class Room( - val id: Int, + val id: String, val message: String? = null, val replyId: HexKey? = null, val draftId: HexKey? = null, - ) : Route() + ) : Route() { + constructor(key: ChatroomKey, message: String? = null, replyId: HexKey? = null, draftId: HexKey? = null) : this( + id = key.users.joinToString(","), + message = message, + replyId = replyId, + draftId = draftId, + ) + + fun toKey(): ChatroomKey = ChatroomKey(id.split(",").toSet()) + } + + @Serializable data class NewPublicMessage( + val to: String, + ) : Route() { + constructor(users: Set) : this( + to = users.joinToString(","), + ) + + fun toKey(): Set = to.split(",").toSet() + } @Serializable data class RoomByAuthor( val id: String, @@ -130,6 +163,43 @@ sealed class Route { val id: String, ) : Route() + @Serializable + data class NewProduct( + val message: String? = null, + val attachment: String? = null, + val quote: String? = null, + val draft: String? = null, + ) : Route() + + @Serializable + data class GeoPost( + val geohash: String? = null, + val message: String? = null, + val attachment: String? = null, + val replyTo: String? = null, + val quote: String? = null, + val draft: String? = null, + ) : Route() + + @Serializable + data class HashtagPost( + val hashtag: String? = null, + val message: String? = null, + val attachment: String? = null, + val replyTo: String? = null, + val quote: String? = null, + val draft: String? = null, + ) : Route() + + @Serializable + data class GenericCommentPost( + val message: String? = null, + val attachment: String? = null, + val replyTo: String? = null, + val quote: String? = null, + val draft: String? = null, + ) : Route() + @Serializable data class NewPost( val message: String? = null, @@ -139,7 +209,6 @@ sealed class Route { val fork: String? = null, val version: String? = null, val draft: String? = null, - val enableGeolocation: Boolean = false, ) : Route() } @@ -158,6 +227,7 @@ fun getRouteWithArguments(navController: NavHostController): Route? { dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() @@ -170,17 +240,48 @@ fun getRouteWithArguments(navController: NavHostController): Route? { dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() - dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() else -> { null } } } + +fun isSameRoute( + currentRoute: Route?, + newRoute: Route, +): Boolean { + if (currentRoute == null) return false + + if (currentRoute == newRoute) { + return true + } + + if (newRoute is Route.EventRedirect) { + return when (currentRoute) { + is Route.Note -> newRoute.id == currentRoute.id + is Route.PublicChatChannel -> newRoute.id == currentRoute.id + else -> false + } + } + + return false +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt new file mode 100644 index 0000000000..cb9cea8e5d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt @@ -0,0 +1,124 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.navigation.topbars + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ActionTopBar( + postRes: Int, + titleRes: Int? = null, + isActive: () -> Boolean = { true }, + onCancel: () -> Unit, + onPost: () -> Unit, +) { + ShorterTopAppBar( + title = { + if (titleRes != null) { + Text( + text = stringRes(titleRes), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } + }, + navigationIcon = { + CloseButton( + modifier = HalfHorzPadding, + onPress = onCancel, + ) + }, + actions = { + Button( + modifier = HalfHorzPadding, + enabled = isActive(), + onClick = onPost, + ) { + Text(text = stringRes(postRes)) + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PostingTopBar( + titleRes: Int? = null, + isActive: () -> Boolean = { true }, + onCancel: () -> Unit, + onPost: () -> Unit, +) = ActionTopBar( + titleRes = titleRes, + postRes = R.string.post, + isActive = isActive, + onCancel = onCancel, + onPost = onPost, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SavingTopBar( + titleRes: Int? = null, + isActive: () -> Boolean = { true }, + onCancel: () -> Unit, + onPost: () -> Unit, +) = ActionTopBar( + titleRes = titleRes, + postRes = R.string.save, + isActive = isActive, + onCancel = onCancel, + onPost = onPost, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CreatingTopBar( + titleRes: Int? = null, + isActive: () -> Boolean = { true }, + onCancel: () -> Unit, + onPost: () -> Unit, +) = ActionTopBar( + titleRes = titleRes, + postRes = R.string.create, + isActive = isActive, + onCancel = onCancel, + onPost = onPost, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AmethystClickableIcon.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/AmethystClickableIcon.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AmethystClickableIcon.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/AmethystClickableIcon.kt index 8aaa8472f1..0d1584ff89 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AmethystClickableIcon.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/AmethystClickableIcon.kt @@ -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,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.navigation +package com.vitorpamplona.amethyst.ui.navigation.topbars import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/FeedFilterSpinner.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index bae4c92635..c031d040a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -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,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.navigation +package com.vitorpamplona.amethyst.ui.navigation.topbars import android.Manifest import androidx.compose.foundation.clickable @@ -38,7 +38,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -48,7 +47,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.map import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState @@ -56,6 +54,7 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName import com.vitorpamplona.amethyst.ui.screen.AroundMeFeedDefinition @@ -66,12 +65,14 @@ import com.vitorpamplona.amethyst.ui.screen.HashtagName import com.vitorpamplona.amethyst.ui.screen.Name import com.vitorpamplona.amethyst.ui.screen.PeopleListName import com.vitorpamplona.amethyst.ui.screen.ResourceName +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import kotlinx.collections.immutable.ImmutableList @OptIn(ExperimentalPermissionsApi::class) @@ -82,6 +83,7 @@ fun FeedFilterSpinner( options: ImmutableList, onSelect: (Int) -> Unit, modifier: Modifier = Modifier, + accountViewModel: AccountViewModel, ) { var optionsShowing by remember { mutableStateOf(false) } @@ -208,14 +210,17 @@ fun FeedFilterSpinner( onSelect(it) }, ) { - RenderOption(it.name) + RenderOption(it.name, accountViewModel) } } } } @Composable -fun RenderOption(option: Name) { +fun RenderOption( + option: Name, + accountViewModel: AccountViewModel, +) { when (option) { is GeoHashName -> { LoadCityName(option.geoHashTag) { @@ -251,13 +256,17 @@ fun RenderOption(option: Name) { horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth(), ) { - val noteState by - option.note - .live() - .metadata - .observeAsState() + val noteState by observeNote(option.note, accountViewModel) - val name = (noteState?.note?.event as? PeopleListEvent)?.nameOrTitle() ?: option.note.dTag() ?: "" + val noteEvent = noteState.note.event + val name = + if (noteEvent is PeopleListEvent) { + noteEvent.nameOrTitle() ?: option.note.dTag() + } else if (noteEvent is FollowListEvent) { + noteEvent.title() ?: option.note.dTag() + } else { + option.note.dTag() + } Text(text = name, color = MaterialTheme.colorScheme.onSurface) } @@ -267,14 +276,9 @@ fun RenderOption(option: Name) { horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth(), ) { - val name by - option.note - .live() - .metadata - .map { "/n/" + ((it.note as? AddressableNote)?.dTag() ?: "") } - .observeAsState() + val it by observeNote(option.note, accountViewModel) - Text(text = name ?: "", color = MaterialTheme.colorScheme.onSurface) + Text(text = "/n/${((it?.note as? AddressableNote)?.dTag() ?: "")}", color = MaterialTheme.colorScheme.onSurface) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ShorterTopAppBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ShorterTopAppBar.kt new file mode 100644 index 0000000000..8f8645eca1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ShorterTopAppBar.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.navigation.topbars + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +val TopBarSize = 50.dp + +@ExperimentalMaterial3Api +@Composable +fun ShorterTopAppBar( + title: @Composable () -> Unit, + modifier: Modifier = Modifier, + navigationIcon: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, + expandedHeight: Dp = TopBarSize, + windowInsets: WindowInsets = TopAppBarDefaults.windowInsets, + colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors(), + scrollBehavior: TopAppBarScrollBehavior? = null, +) = TopAppBar( + title, + modifier, + navigationIcon, + actions, + expandedHeight, + windowInsets, + colors, + scrollBehavior, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/TopBarExtensibleWithBackButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/TopBarExtensibleWithBackButton.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/TopBarExtensibleWithBackButton.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/TopBarExtensibleWithBackButton.kt index 23883c6cc3..9b652d99cf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/TopBarExtensibleWithBackButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/TopBarExtensibleWithBackButton.kt @@ -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,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.navigation +package com.vitorpamplona.amethyst.ui.navigation.topbars import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column @@ -31,7 +31,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -69,8 +68,7 @@ fun MyExtensibleTopAppBar( Column( Modifier.clickable { expanded.value = !expanded.value }, ) { - TopAppBar( - scrollBehavior = rememberHeightDecreaser(), + ShorterTopAppBar( title = { Row( verticalAlignment = Alignment.CenterVertically, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/TopBarWithBackButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/TopBarWithBackButton.kt similarity index 79% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/TopBarWithBackButton.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/TopBarWithBackButton.kt index 406b9e1f67..2a0b46ba7d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/TopBarWithBackButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/TopBarWithBackButton.kt @@ -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,14 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.navigation +package com.vitorpamplona.amethyst.ui.navigation.topbars import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButton import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon @OptIn(ExperimentalMaterial3Api::class) @@ -34,17 +33,18 @@ fun TopBarWithBackButton( caption: String, popBack: () -> Unit, ) { - TopAppBar( - scrollBehavior = rememberHeightDecreaser(), - title = { Text(caption) }, + ShorterTopAppBar( + title = { + Text( + text = caption, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + }, navigationIcon = { - IconButton( - onClick = popBack, - modifier = Modifier, - ) { + IconButton(popBack) { ArrowBackIcon() } }, - actions = {}, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt similarity index 66% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt index c8fdbba029..f5bfc66605 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt @@ -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,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.navigation +package com.vitorpamplona.amethyst.ui.navigation.topbars import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -26,43 +26,32 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.FeatureSetType +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPicture import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.SearchIcon -import com.vitorpamplona.amethyst.ui.screen.FeedDefinition -import com.vitorpamplona.amethyst.ui.screen.FollowListState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier import com.vitorpamplona.amethyst.ui.theme.Size22Modifier import com.vitorpamplona.amethyst.ui.theme.placeholderText -@Composable -fun MainTopBar( - accountViewModel: AccountViewModel, - nav: INav, -) { - GenericMainTopBar(accountViewModel, nav) { AmethystClickableIcon() } -} - @OptIn(ExperimentalMaterial3Api::class) @Composable -fun GenericMainTopBar( +fun UserDrawerSearchTopBar( accountViewModel: AccountViewModel, nav: INav, content: @Composable () -> Unit, ) { - TopAppBar( - scrollBehavior = rememberHeightDecreaser(), + ShorterTopAppBar( title = { Column( modifier = Modifier.fillMaxWidth(), @@ -89,12 +78,7 @@ private fun LoggedInUserPictureDrawer( onClick: () -> Unit, ) { IconButton(onClick = onClick) { - val profilePicture by - accountViewModel.account - .userProfile() - .live() - .profilePictureChanges - .observeAsState() + val profilePicture by observeUserPicture(accountViewModel.userProfile(), accountViewModel) RobohashFallbackAsyncImage( robot = accountViewModel.userProfile().pubkeyHex, @@ -107,35 +91,3 @@ private fun LoggedInUserPictureDrawer( ) } } - -@Composable -fun FollowListWithRoutes( - followListsModel: FollowListState, - listName: String, - onChange: (FeedDefinition) -> Unit, -) { - val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle() - - FeedFilterSpinner( - placeholderCode = listName, - explainer = stringRes(R.string.select_list_to_filter), - options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.kind3Follow) }, - ) -} - -@Composable -fun FollowListWithoutRoutes( - followListsModel: FollowListState, - listName: String, - onChange: (FeedDefinition) -> Unit, -) { - val allLists by followListsModel.kind3GlobalPeople.collectAsStateWithLifecycle() - - FeedFilterSpinner( - placeholderCode = listName, - explainer = stringRes(R.string.select_list_to_filter), - options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.kind3Follow) }, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt index b43397112c..717a4439c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt @@ -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 @@ -36,15 +36,15 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -61,10 +61,7 @@ fun BadgeCompose( accountViewModel: AccountViewModel, nav: INav, ) { - val noteState by likeSetCard.note - .live() - .metadata - .observeAsState() + val noteState by observeNote(likeSetCard.note, accountViewModel) val note = noteState?.note val context = LocalContext.current.applicationContext @@ -87,7 +84,7 @@ fun BadgeCompose( onClick = { routeFor( note, - accountViewModel.userProfile(), + accountViewModel.account, )?.let { nav.nav(it) } }, ), @@ -132,7 +129,7 @@ fun BadgeCompose( } note.replyTo?.firstOrNull()?.let { - BadgeDisplay(baseNote = it) + BadgeDisplay(baseNote = it, accountViewModel) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt index b98804779b..35497746eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt @@ -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 @@ -37,8 +37,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -147,8 +147,8 @@ fun HiddenNote( NoteAuthorPicture( baseNote = it, size = Size35dp, - nav = nav, accountViewModel = accountViewModel, + nav = nav, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlockReportChecker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlockReportChecker.kt index 3d5409ee85..bdcfa1d943 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlockReportChecker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlockReportChecker.kt @@ -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,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.note -import android.R.attr.onClick import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -28,8 +27,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav.nav -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt deleted file mode 100644 index 75c952aea0..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt +++ /dev/null @@ -1,1073 +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.ui.note - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Alignment.Companion.BottomStart -import androidx.compose.ui.Alignment.Companion.CenterVertically -import androidx.compose.ui.Alignment.Companion.TopEnd -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.lifecycle.distinctUntilChanged -import androidx.lifecycle.map -import coil3.compose.AsyncImage -import coil3.compose.AsyncImagePainter -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Channel -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.ParticipantListBuilder -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled -import com.vitorpamplona.amethyst.ui.components.SensitivityWarning -import com.vitorpamplona.amethyst.ui.layouts.LeftPictureLayout -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.note.elements.BannerImage -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ChannelHeader -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.EndedFlag -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveFlag -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.OfflineFlag -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.ScheduledFlag -import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.observeAppDefinition -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CheckIfVideoIsOnline -import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer -import com.vitorpamplona.amethyst.ui.theme.HalfPadding -import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding -import com.vitorpamplona.amethyst.ui.theme.QuoteBorder -import com.vitorpamplona.amethyst.ui.theme.RowColSpacing -import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp -import com.vitorpamplona.amethyst.ui.theme.Size25dp -import com.vitorpamplona.amethyst.ui.theme.Size5dp -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.StdPadding -import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer -import com.vitorpamplona.amethyst.ui.theme.bitcoinColor -import com.vitorpamplona.amethyst.ui.theme.grayText -import com.vitorpamplona.amethyst.ui.theme.nip05 -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent -import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent -import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag -import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag -import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent -import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent -import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent -import com.vitorpamplona.quartz.nip99Classifieds.tags.PriceTag -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -@Composable -fun ChannelCardCompose( - baseNote: Note, - routeForLastRead: String? = null, - modifier: Modifier = Modifier, - parentBackgroundColor: MutableState? = null, - forceEventKind: Int?, - isHiddenFeed: Boolean = false, - accountViewModel: AccountViewModel, - nav: INav, -) { - WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel) { - if (forceEventKind == null || baseNote.event?.kind == forceEventKind) { - CheckHiddenFeedWatchBlockAndReport( - note = baseNote, - modifier = modifier, - ignoreAllBlocksAndReports = isHiddenFeed, - showHiddenWarning = false, - accountViewModel = accountViewModel, - nav = nav, - ) { canPreview -> - NormalChannelCard( - baseNote = baseNote, - routeForLastRead = routeForLastRead, - modifier = modifier, - parentBackgroundColor = parentBackgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } -} - -@Composable -fun NormalChannelCard( - baseNote: Note, - routeForLastRead: String? = null, - modifier: Modifier = Modifier, - parentBackgroundColor: MutableState? = null, - accountViewModel: AccountViewModel, - nav: INav, -) { - LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup -> - CheckNewAndRenderChannelCard( - baseNote, - routeForLastRead, - modifier, - parentBackgroundColor, - accountViewModel, - showPopup, - nav, - ) - } -} - -@Composable -private fun CheckNewAndRenderChannelCard( - baseNote: Note, - routeForLastRead: String? = null, - modifier: Modifier = Modifier, - parentBackgroundColor: MutableState? = null, - accountViewModel: AccountViewModel, - showPopup: () -> Unit, - nav: INav, -) { - val backgroundColor = - calculateBackgroundColor( - createdAt = baseNote.createdAt(), - routeForLastRead = routeForLastRead, - parentBackgroundColor = parentBackgroundColor, - accountViewModel = accountViewModel, - ) - - ClickableNote( - baseNote = baseNote, - backgroundColor = backgroundColor, - modifier = modifier, - accountViewModel = accountViewModel, - showPopup = showPopup, - nav = nav, - ) { - InnerChannelCardWithReactions( - baseNote = baseNote, - accountViewModel = accountViewModel, - nav = nav, - ) - } -} - -@Composable -fun InnerChannelCardWithReactions( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: INav, -) { - when (baseNote.event) { - is LiveActivitiesEvent -> { - InnerCardRow(baseNote, accountViewModel, nav) - } - is CommunityDefinitionEvent -> { - InnerCardRow(baseNote, accountViewModel, nav) - } - is ChannelCreateEvent -> { - InnerCardRow(baseNote, accountViewModel, nav) - } - is ClassifiedsEvent -> { - InnerCardBox(baseNote, accountViewModel, nav) - } - is AppDefinitionEvent -> { - InnerCardRow(baseNote, accountViewModel, nav) - } - } -} - -@Composable -fun InnerCardRow( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: INav, -) { - Column(StdPadding) { - SensitivityWarning( - note = baseNote, - accountViewModel = accountViewModel, - ) { - RenderNoteRow( - baseNote, - accountViewModel, - nav, - ) - } - } -} - -@Composable -fun InnerCardBox( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: INav, -) { - Column(HalfPadding) { - SensitivityWarning( - note = baseNote, - accountViewModel = accountViewModel, - ) { - RenderClassifiedsThumb(baseNote, accountViewModel, nav) - } - } -} - -@Composable -private fun RenderNoteRow( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: INav, -) { - when (baseNote.event) { - is LiveActivitiesEvent -> { - RenderLiveActivityThumb(baseNote, accountViewModel, nav) - } - is CommunityDefinitionEvent -> { - RenderCommunitiesThumb(baseNote, accountViewModel, nav) - } - is ChannelCreateEvent -> { - RenderChannelThumb(baseNote, accountViewModel, nav) - } - is AppDefinitionEvent -> { - RenderContentDVMThumb(baseNote, accountViewModel, nav) - } - } -} - -@Immutable -data class ClassifiedsThumb( - val image: String?, - val title: String?, - val price: PriceTag?, -) - -@Composable -fun RenderClassifiedsThumb( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: INav, -) { - val noteEvent = baseNote.event as? ClassifiedsEvent ?: return - - val card by - baseNote - .live() - .metadata - .map { - val noteEvent = it.note.event as? ClassifiedsEvent - - ClassifiedsThumb( - image = noteEvent?.image(), - title = noteEvent?.title(), - price = noteEvent?.price(), - ) - }.distinctUntilChanged() - .observeAsState( - ClassifiedsThumb( - image = noteEvent.image(), - title = noteEvent.title(), - price = noteEvent.price(), - ), - ) - - InnerRenderClassifiedsThumb(card, baseNote) -} - -@Preview -@Composable -fun RenderClassifiedsThumbPreview() { - Surface(Modifier.size(200.dp)) { - InnerRenderClassifiedsThumb( - card = - ClassifiedsThumb( - image = null, - title = "Like New", - price = PriceTag("800000", "SATS", null), - ), - note = Note("hex"), - ) - } -} - -@Composable -fun InnerRenderClassifiedsThumb( - card: ClassifiedsThumb, - note: Note, -) { - Box( - Modifier - .fillMaxWidth() - .aspectRatio(1f), - contentAlignment = BottomStart, - ) { - card.image?.let { - AsyncImage( - model = it, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - } ?: run { DisplayAuthorBanner(note) } - - Row( - Modifier - .fillMaxWidth() - .background(Color.Black.copy(0.6f)) - .padding(Size5dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - card.title?.let { - Text( - text = it, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = Color.White, - modifier = Modifier.weight(1f), - ) - } - - card.price?.let { - val priceTag = - remember(card) { - val newAmount = it.amount.toBigDecimalOrNull()?.let { showAmountInteger(it) } ?: it.amount - - if (it.frequency != null && it.currency != null) { - "$newAmount ${it.currency}/${it.frequency}" - } else if (it.currency != null) { - "$newAmount ${it.currency}" - } else { - newAmount - } - } - - Text( - text = priceTag, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = Color.White, - ) - } - } - } -} - -@Immutable -data class LiveActivityCard( - val name: String, - val cover: String?, - val media: String?, - val subject: String?, - val content: String?, - val participants: ImmutableList, - val status: String?, - val starts: Long?, -) - -@Composable -fun RenderLiveActivityThumb( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: INav, -) { - val noteEvent = baseNote.event as? LiveActivitiesEvent ?: return - - val card by - baseNote - .live() - .metadata - .map { - val noteEvent = it.note.event as? LiveActivitiesEvent - - LiveActivityCard( - name = noteEvent?.dTag() ?: "", - cover = noteEvent?.image()?.ifBlank { null }, - media = noteEvent?.streaming(), - subject = noteEvent?.title()?.ifBlank { null }, - content = noteEvent?.summary(), - participants = noteEvent?.participants()?.toImmutableList() ?: persistentListOf(), - status = noteEvent?.status(), - starts = noteEvent?.starts(), - ) - }.distinctUntilChanged() - .observeAsState( - LiveActivityCard( - name = noteEvent.dTag(), - cover = noteEvent.image()?.ifBlank { null }, - media = noteEvent.streaming(), - subject = noteEvent.title()?.ifBlank { null }, - content = noteEvent.summary(), - participants = noteEvent.participants().toImmutableList(), - status = noteEvent.status(), - starts = noteEvent.starts(), - ), - ) - - Column( - modifier = Modifier.fillMaxWidth(), - ) { - Box( - contentAlignment = TopEnd, - modifier = - Modifier - .aspectRatio(ratio = 16f / 9f) - .fillMaxWidth(), - ) { - card.cover?.let { - AsyncImage( - model = it, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = - Modifier - .fillMaxSize() - .clip(QuoteBorder), - ) - } ?: run { DisplayAuthorBanner(baseNote) } - - Box(Modifier.padding(10.dp)) { - CrossfadeIfEnabled(targetState = card.status, label = "RenderLiveActivityThumb", accountViewModel = accountViewModel) { - when (it) { - StatusTag.STATUS.LIVE.code -> { - val url = card.media - if (url.isNullOrBlank()) { - LiveFlag() - } else { - CheckIfVideoIsOnline(url, accountViewModel) { isOnline -> - if (isOnline) { - LiveFlag() - } else { - OfflineFlag() - } - } - } - } - StatusTag.STATUS.ENDED.code -> { - EndedFlag() - } - StatusTag.STATUS.PLANNED.code -> { - ScheduledFlag(card.starts) - } - else -> { - EndedFlag() - } - } - } - } - - LoadParticipants(card.participants, baseNote, accountViewModel) { participantUsers -> - Box( - Modifier - .padding(10.dp) - .align(BottomStart), - ) { - if (participantUsers.isNotEmpty()) { - Gallery(participantUsers, Modifier, accountViewModel) - } - } - } - } - - Spacer(modifier = DoubleVertSpacer) - - ChannelHeader( - channelHex = baseNote.idHex, - showVideo = false, - showFlag = false, - sendToChannel = true, - modifier = Modifier, - accountViewModel = accountViewModel, - nav = nav, - ) - } -} - -@Immutable -data class CommunityCard( - val name: String, - val description: String?, - val cover: String?, - val moderators: ImmutableList, -) - -@Immutable -data class DVMCard( - val name: String, - val description: String?, - val cover: String?, - val amount: String?, - val personalized: Boolean?, -) - -@Composable -fun RenderCommunitiesThumb( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: INav, -) { - val noteEvent = baseNote.event as? CommunityDefinitionEvent ?: return - - val card by - baseNote - .live() - .metadata - .map { - val noteEvent = it.note.event as? CommunityDefinitionEvent - - CommunityCard( - name = noteEvent?.dTag() ?: "", - description = noteEvent?.description(), - cover = noteEvent?.image()?.imageUrl, - moderators = noteEvent?.moderatorKeys()?.toImmutableList() ?: persistentListOf(), - ) - }.distinctUntilChanged() - .observeAsState( - CommunityCard( - name = noteEvent.dTag(), - description = noteEvent.description(), - cover = noteEvent.image()?.imageUrl, - moderators = noteEvent.moderatorKeys().toImmutableList(), - ), - ) - - LeftPictureLayout( - onImage = { - card.cover?.let { - Box(contentAlignment = BottomStart) { - AsyncImage( - model = it, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = - Modifier - .fillMaxSize() - .clip(QuoteBorder), - ) - } - } ?: run { DisplayAuthorBanner(baseNote) } - }, - onTitleRow = { - Text( - text = card.name, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - - Spacer(modifier = StdHorzSpacer) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = RowColSpacing, - ) { - LikeReaction( - baseNote = baseNote, - grayTint = MaterialTheme.colorScheme.onSurface, - accountViewModel = accountViewModel, - nav, - ) - } - Spacer(modifier = StdHorzSpacer) - ZapReaction( - baseNote = baseNote, - grayTint = MaterialTheme.colorScheme.onSurface, - accountViewModel = accountViewModel, - nav = nav, - ) - }, - onDescription = { - Text( - text = card.description ?: stringRes(R.string.community_about_topic, card.name), - color = MaterialTheme.colorScheme.grayText, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - fontSize = 14.sp, - lineHeight = 18.sp, - modifier = HalfTopPadding, - ) - }, - onBottomRow = { - LoadModerators(card.moderators, baseNote, accountViewModel) { participantUsers -> - if (participantUsers.isNotEmpty()) { - Gallery(participantUsers, HalfTopPadding, accountViewModel) - } - } - }, - ) -} - -@Composable -fun LoadModerators( - moderators: ImmutableList, - baseNote: Note, - accountViewModel: AccountViewModel, - content: @Composable (ImmutableList) -> Unit, -) { - var participantUsers by remember { - mutableStateOf>( - persistentListOf(), - ) - } - - LaunchedEffect(key1 = moderators) { - launch(Dispatchers.IO) { - val hosts = - moderators.mapNotNull { part -> - if (part != baseNote.author?.pubkeyHex) { - LocalCache.checkGetOrCreateUser(part) - } else { - null - } - } - - val followingKeySet = - accountViewModel.account.liveDiscoveryFollowLists.value - ?.authors - val allParticipants = - ParticipantListBuilder().followsThatParticipateOn(baseNote, followingKeySet).minus(hosts) - - val newParticipantUsers = - if (followingKeySet == null) { - val allFollows = accountViewModel.account.liveKind3Follows.value.authors - val followingParticipants = - ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).minus(hosts) - - (hosts + followingParticipants + (allParticipants - followingParticipants)) - .toImmutableList() - } else { - (hosts + allParticipants).toImmutableList() - } - - if (!equalImmutableLists(newParticipantUsers, participantUsers)) { - participantUsers = newParticipantUsers - } - } - } - - content(participantUsers) -} - -@Composable -private fun LoadParticipants( - participants: ImmutableList, - baseNote: Note, - accountViewModel: AccountViewModel, - inner: @Composable (ImmutableList) -> Unit, -) { - var participantUsers by remember { - mutableStateOf>( - persistentListOf(), - ) - } - - LaunchedEffect(key1 = participants) { - launch(Dispatchers.IO) { - val hosts = - participants.mapNotNull { part -> - if (part.pubKey != baseNote.author?.pubkeyHex) { - LocalCache.checkGetOrCreateUser(part.pubKey) - } else { - null - } - } - - val hostsAuthor = hosts + (baseNote.author?.let { listOf(it) } ?: emptyList()) - - val followingKeySet = - accountViewModel.account.liveDiscoveryFollowLists.value - ?.authors - - val allParticipants = - ParticipantListBuilder() - .followsThatParticipateOn(baseNote, followingKeySet) - .minus(hostsAuthor) - - val newParticipantUsers = - if (followingKeySet == null) { - val allFollows = accountViewModel.account.liveKind3Follows.value.authors - val followingParticipants = - ParticipantListBuilder() - .followsThatParticipateOn(baseNote, allFollows) - .minus(hostsAuthor) - - (hosts + followingParticipants + (allParticipants - followingParticipants)) - .toImmutableList() - } else { - (hosts + allParticipants).toImmutableList() - } - - if (!equalImmutableLists(newParticipantUsers, participantUsers)) { - participantUsers = newParticipantUsers - } - } - } - - inner(participantUsers) -} - -@Composable -fun RenderContentDVMThumb( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: INav, -) { - // downloads user metadata to pre-load the NIP-65 relays. - val user = - baseNote.author - ?.live() - ?.metadata - ?.observeAsState() - - val card = observeAppDefinition(appDefinitionNote = baseNote) - - LeftPictureLayout( - imageFraction = 0.20f, - onImage = { - card.cover?.let { - Box(contentAlignment = BottomStart) { - AsyncImage( - model = it, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = - Modifier - .fillMaxSize() - .clip(QuoteBorder), - ) - } - } ?: run { - user?.value?.user?.let { - BannerImage( - it, - Modifier - .fillMaxSize() - .clip(QuoteBorder), - ) - } - } - }, - onTitleRow = { - Text( - text = card.name, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - Spacer(modifier = StdVertSpacer) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = RowColSpacing5dp, - ) { - LikeReaction( - baseNote = baseNote, - grayTint = MaterialTheme.colorScheme.onSurface, - accountViewModel = accountViewModel, - nav, - ) - } - Spacer(modifier = StdHorzSpacer) - ZapReaction( - baseNote = baseNote, - grayTint = MaterialTheme.colorScheme.onSurface, - accountViewModel = accountViewModel, - nav = nav, - ) - }, - onDescription = { - card.description?.let { - Text( - text = it, - color = MaterialTheme.colorScheme.grayText, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - fontSize = 14.sp, - lineHeight = 16.sp, - modifier = HalfTopPadding, - ) - } - }, - onBottomRow = { - card.amount?.let { - var color = Color.DarkGray - var amount = it - if (card.amount == "free" || card.amount == "0") { - color = MaterialTheme.colorScheme.secondary - amount = "Free" - } else if (card.amount == "flexible") { - color = MaterialTheme.colorScheme.primaryContainer - amount = "Flexible" - } else if (card.amount == "") { - color = MaterialTheme.colorScheme.grayText - amount = "Unknown" - } else { - color = MaterialTheme.colorScheme.primary - amount = card.amount + " Sats" - } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Absolute.Right, - ) { - Text( - textAlign = TextAlign.End, - text = " $amount ", - color = color, - maxLines = 3, - modifier = - Modifier - .weight(1f, fill = false) - .border(Dp(.1f), color, shape = RoundedCornerShape(20)), - fontSize = 12.sp, - ) - } - } - Spacer(modifier = StdHorzSpacer) - card.personalized?.let { - var color = Color.DarkGray - var name = "generic" - if (card.personalized == true) { - color = MaterialTheme.colorScheme.bitcoinColor - name = "Personalized" - } else { - color = MaterialTheme.colorScheme.nip05 - name = "Generic" - } - Spacer(modifier = StdVertSpacer) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Absolute.Right, - ) { - Text( - textAlign = TextAlign.End, - text = " $name ", - color = color, - maxLines = 3, - modifier = - Modifier - .padding(start = 4.dp) - .weight(1f, fill = false) - .border(Dp(.1f), color, shape = RoundedCornerShape(20)), - fontSize = 12.sp, - ) - } - } - }, - ) -} - -@Composable -fun RenderChannelThumb( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: INav, -) { - val noteEvent = baseNote.event as? ChannelCreateEvent ?: return - - LoadChannel(baseChannelHex = baseNote.idHex, accountViewModel) { - RenderChannelThumb(baseNote = baseNote, channel = it, accountViewModel, nav) - } -} - -@Composable -fun RenderChannelThumb( - baseNote: Note, - channel: Channel, - accountViewModel: AccountViewModel, - nav: INav, -) { - val channelUpdates by channel.live.observeAsState() - - val name = remember(channelUpdates) { channelUpdates?.channel?.toBestDisplayName() ?: "" } - val description = remember(channelUpdates) { channelUpdates?.channel?.summary()?.ifBlank { null } } - var cover by - remember(channelUpdates) { - mutableStateOf(channelUpdates?.channel?.profilePicture()?.ifBlank { null }) - } - - var participantUsers by - remember(baseNote) { - mutableStateOf>( - persistentListOf(), - ) - } - - LaunchedEffect(key1 = channelUpdates) { - launch(Dispatchers.IO) { - val followingKeySet = - accountViewModel.account.liveDiscoveryFollowLists.value - ?.authors - val allParticipants = - ParticipantListBuilder() - .followsThatParticipateOn(baseNote, followingKeySet) - .toImmutableList() - - val newParticipantUsers = - if (followingKeySet == null) { - val allFollows = accountViewModel.account.liveKind3Follows.value.authors - val followingParticipants = - ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).toList() - - (followingParticipants + (allParticipants - followingParticipants)).toImmutableList() - } else { - allParticipants.toImmutableList() - } - - if (!equalImmutableLists(newParticipantUsers, participantUsers)) { - participantUsers = newParticipantUsers - } - } - } - - LeftPictureLayout( - onImage = { - cover?.let { - AsyncImage( - model = it, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = - Modifier - .fillMaxSize() - .clip(QuoteBorder), - onState = { - if (it is AsyncImagePainter.State.Error) { - cover = null - } - }, - ) - } ?: run { DisplayAuthorBanner(baseNote) } - }, - onTitleRow = { - Text( - text = name, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - Spacer(modifier = StdHorzSpacer) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = RowColSpacing, - ) { - LikeReaction( - baseNote = baseNote, - grayTint = MaterialTheme.colorScheme.onSurface, - accountViewModel = accountViewModel, - nav, - ) - } - Spacer(modifier = StdHorzSpacer) - ZapReaction( - baseNote = baseNote, - grayTint = MaterialTheme.colorScheme.onSurface, - accountViewModel = accountViewModel, - nav = nav, - ) - }, - onDescription = { - Text( - text = description ?: stringRes(R.string.chat_about_topic, name), - color = MaterialTheme.colorScheme.grayText, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - fontSize = 14.sp, - lineHeight = 18.sp, - modifier = HalfTopPadding, - ) - }, - onBottomRow = { - if (participantUsers.isNotEmpty()) { - Gallery(participantUsers, HalfTopPadding, accountViewModel) - } - }, - ) -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun Gallery( - users: ImmutableList, - modifier: Modifier, - accountViewModel: AccountViewModel, -) { - FlowRow(modifier, verticalArrangement = Arrangement.Center) { - users.take(6).forEach { ClickableUserPicture(it, Size25dp, accountViewModel) } - - if (users.size > 6) { - Text( - text = " + " + showCount(users.size - 6), - fontSize = 13.sp, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(start = 3.dp).align(CenterVertically), - ) - } - } -} - -@Composable -fun DisplayAuthorBanner(note: Note) { - WatchAuthor(note) { - BannerImage( - it, - Modifier - .fillMaxSize() - .clip(QuoteBorder), - ) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/DisplayAuthorBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/DisplayAuthorBanner.kt new file mode 100644 index 0000000000..81de3a9ea1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/DisplayAuthorBanner.kt @@ -0,0 +1,43 @@ +/** + * 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.ui.note + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.note.elements.BannerImage +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.SimpleImageBorder + +@Composable +fun DisplayAuthorBanner( + note: Note, + accountViewModel: AccountViewModel, + modifier: Modifier = SimpleImageBorder, +) { + WatchAuthor(note, accountViewModel) { + BannerImage( + it, + modifier, + accountViewModel, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ErrorMessageDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ErrorMessageDialog.kt index a9a6912124..cc9fb379f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ErrorMessageDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ErrorMessageDialog.kt @@ -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 @@ -39,10 +39,10 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size16dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer @@ -81,7 +81,7 @@ fun ErrorMessageDialog( onClickStartMessage?.let { TextButton(onClick = onClickStartMessage) { Icon( - painter = painterResource(R.drawable.ic_dm), + painter = painterRes(R.drawable.ic_dm, 3), contentDescription = null, ) Spacer(StdHorzSpacer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Gallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Gallery.kt new file mode 100644 index 0000000000..25f1c7e159 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Gallery.kt @@ -0,0 +1,83 @@ +/** + * 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.ui.note + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size25dp +import kotlinx.collections.immutable.ImmutableList + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun Gallery( + users: ImmutableList, + modifier: Modifier, + accountViewModel: AccountViewModel, + nav: INav, + maxPictures: Int = 6, +) { + FlowRow( + modifier, + verticalArrangement = Arrangement.Center, + horizontalArrangement = Arrangement.spacedBy((-5).dp), + ) { + users.take(maxPictures).forEach { + ClickableUserPicture( + it, + Size25dp, + accountViewModel, + onClick = { + nav.nav(routeFor(it)) + }, + ) + } + + if (users.size > maxPictures) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(Size25dp).clip(shape = CircleShape).background(MaterialTheme.colorScheme.secondaryContainer), + ) { + Text( + text = "+" + showCount(users.size - maxPictures), + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt index 5673d420bf..6bd827c590 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt @@ -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 @@ -31,17 +31,15 @@ import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.DownloadForOffline -import androidx.compose.material.icons.filled.Downloading import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PushPin -import androidx.compose.material.icons.filled.Report import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.outlined.AddReaction -import androidx.compose.material.icons.outlined.Bolt -import androidx.compose.material.icons.outlined.OpenInNew +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Mic import androidx.compose.material.icons.outlined.PlayCircle import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -50,7 +48,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import com.vitorpamplona.amethyst.R @@ -108,16 +105,6 @@ fun ArrowBackIcon(tint: Color = MaterialTheme.colorScheme.grayText) { ) } -@Composable -fun MessageIcon(modifier: Modifier) { - Icon( - painter = painterResource(R.drawable.ic_dm), - null, - modifier = modifier, - tint = MaterialTheme.colorScheme.primary, - ) -} - @Composable fun DownloadForOfflineIcon( iconSize: Dp, @@ -131,26 +118,6 @@ fun DownloadForOfflineIcon( ) } -@Composable -fun HashCheckIcon(iconSize: Dp) { - Icon( - painter = painterResource(R.drawable.original), - contentDescription = stringRes(id = R.string.hash_verification_passed), - modifier = remember(iconSize) { Modifier.size(iconSize) }, - tint = Color.Unspecified, - ) -} - -@Composable -fun HashCheckFailedIcon(iconSize: Dp) { - Icon( - imageVector = Icons.Default.Report, - contentDescription = stringRes(id = R.string.hash_verification_failed), - modifier = remember(iconSize) { Modifier.size(iconSize) }, - tint = Color.Red, - ) -} - @Composable fun LikedIcon( modifier: Modifier, @@ -348,6 +315,19 @@ fun ExpandMoreIcon( ) } +@Composable +fun VoiceReplyIcon( + iconSizeModifier: Modifier, + tint: Color, +) { + Icon( + imageVector = Icons.Outlined.Mic, + contentDescription = stringRes(id = R.string.record_a_message), + tint = tint, + modifier = iconSizeModifier, + ) +} + @Composable fun CommentIcon( iconSizeModifier: Modifier, @@ -361,26 +341,6 @@ fun CommentIcon( ) } -@Composable -fun PollIcon() { - Icon( - painter = painterResource(R.drawable.ic_poll), - contentDescription = stringRes(id = R.string.poll), - modifier = Size20Modifier, - tint = MaterialTheme.colorScheme.onBackground, - ) -} - -@Composable -fun RegularPostIcon() { - Icon( - painter = painterResource(R.drawable.ic_lists), - contentDescription = stringRes(id = R.string.disable_poll), - modifier = Size20Modifier, - tint = MaterialTheme.colorScheme.onBackground, - ) -} - @Composable fun CancelIcon() { Icon( @@ -394,7 +354,7 @@ fun CancelIcon() { @Composable fun CloseIcon() { Icon( - painter = painterResource(id = R.drawable.ic_close), + imageVector = Icons.Outlined.Close, contentDescription = stringRes(id = R.string.cancel), modifier = Size20Modifier, ) @@ -482,59 +442,3 @@ fun VerticalDotsIcon() { tint = MaterialTheme.colorScheme.placeholderText, ) } - -@Composable -fun NIP05CheckingIcon(modifier: Modifier) { - Icon( - imageVector = Icons.Default.Downloading, - contentDescription = stringRes(id = R.string.nip05_checking), - modifier = modifier, - tint = Color.Yellow, - ) -} - -@Composable -fun NIP05VerifiedIcon(modifier: Modifier) { - Icon( - painter = painterResource(R.drawable.nip_05), - contentDescription = stringRes(id = R.string.nip05_verified), - modifier = modifier, - tint = Color.Unspecified, - ) -} - -@Composable -fun NIP05FailedVerification(modifier: Modifier) { - Icon( - imageVector = Icons.Default.Report, - contentDescription = stringRes(id = R.string.nip05_failed), - modifier = modifier, - tint = Color.Red, - ) -} - -@Composable -fun IncognitoIconOn( - modifier: Modifier, - tint: Color, -) { - Icon( - painter = painterResource(id = R.drawable.incognito), - contentDescription = stringRes(id = R.string.accessibility_turn_off_sealed_message), - modifier = modifier, - tint = tint, - ) -} - -@Composable -fun IncognitoIconOff( - modifier: Modifier, - tint: Color, -) { - Icon( - painter = painterResource(id = R.drawable.incognito_off), - contentDescription = stringRes(id = R.string.accessibility_turn_on_sealed_message), - modifier = modifier, - tint = tint, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt index 6c9b654888..570c04cb04 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt @@ -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,25 +22,30 @@ package com.vitorpamplona.amethyst.ui.note import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.ProduceStateScope +import androidx.compose.runtime.State import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteOts +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserStatuses import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists +import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.CoroutineContext @Composable fun LoadDecryptedContent( @@ -82,50 +87,19 @@ fun LoadDecryptedContentOrNull( inner(decryptedContent) } -@Composable -fun LoadAddressableNote( - aTagHex: String, - accountViewModel: AccountViewModel, - content: @Composable (AddressableNote?) -> Unit, -) { - var note by - remember(aTagHex) { - mutableStateOf(accountViewModel.getAddressableNoteIfExists(aTagHex)) - } - - if (note == null) { - LaunchedEffect(key1 = aTagHex) { - accountViewModel.checkGetOrCreateAddressableNote(aTagHex) { newNote -> - if (newNote != note) { - note = newNote - } - } - } - } - - content(note) -} - @Composable fun LoadAddressableNote( address: Address, accountViewModel: AccountViewModel, content: @Composable (AddressableNote?) -> Unit, ) { - var note by - remember(address) { - mutableStateOf(accountViewModel.getAddressableNoteIfExists(address.toValue())) - } - - if (note == null) { - LaunchedEffect(key1 = address) { - val newNote = - withContext(Dispatchers.IO) { - accountViewModel.getOrCreateAddressableNote(address) - } - if (note != newNote) { - note = newNote - } + val note by produceState( + accountViewModel.getAddressableNoteIfExists(address), + address, + ) { + val newNote = accountViewModel.getOrCreateAddressableNote(address) + if (newNote != value) { + value = newNote } } @@ -138,19 +112,9 @@ fun LoadStatuses( accountViewModel: AccountViewModel, content: @Composable (ImmutableList) -> Unit, ) { - var statuses: ImmutableList by remember { mutableStateOf(persistentListOf()) } + val userStatuses by observeUserStatuses(user, accountViewModel) - val userStatus by user.live().statuses.observeAsState() - - LaunchedEffect(key1 = userStatus) { - accountViewModel.findStatusesForUser(userStatus?.user ?: user) { newStatuses -> - if (!equalImmutableLists(statuses, newStatuses)) { - statuses = newStatuses - } - } - } - - content(statuses) + content(userStatuses) } @Composable @@ -162,7 +126,7 @@ fun LoadOts( ) { var earliestDate: GenericLoadable by remember { mutableStateOf(GenericLoadable.Loading()) } - val noteStatus by note.live().innerOts.observeAsState() + val noteStatus by observeNoteOts(note, accountViewModel) LaunchedEffect(key1 = noteStatus) { accountViewModel.findOtsEventsForNote(noteStatus?.note ?: note) { newOts -> @@ -189,23 +153,70 @@ fun LoadOts( } @Composable -fun LoadChannel( - baseChannelHex: String, +fun LoadPublicChatChannel( + id: String, accountViewModel: AccountViewModel, - content: @Composable (Channel) -> Unit, + content: @Composable (PublicChatChannel) -> Unit, ) { - var channel by - remember(baseChannelHex) { - mutableStateOf(accountViewModel.getChannelIfExists(baseChannelHex)) + val channel = + produceStateIfNotNull(accountViewModel.getPublicChatChannelIfExists(id), id) { + value = accountViewModel.checkGetOrCreatePublicChatChannel(id) } - if (channel == null) { - LaunchedEffect(key1 = baseChannelHex) { - accountViewModel.checkGetOrCreateChannel(baseChannelHex) { newChannel -> - launch(Dispatchers.Main) { channel = newChannel } - } + channel.value?.let { content(it) } +} + +@Composable +fun LoadEphemeralChatChannel( + id: RoomId, + accountViewModel: AccountViewModel, + content: @Composable (EphemeralChatChannel) -> Unit, +) { + val channel = + produceStateIfNotNull(accountViewModel.getEphemeralChatChannelIfExists(id), id) { + value = accountViewModel.checkGetOrCreateEphemeralChatChannel(id) + } + + channel.value?.let { content(it) } +} + +@Composable +fun LoadLiveActivityChannel( + id: Address, + accountViewModel: AccountViewModel, + content: @Composable (LiveActivitiesChannel) -> Unit, +) { + val channel = + produceStateIfNotNull(accountViewModel.getLiveActivityChannelIfExists(id), id) { + value = accountViewModel.checkGetOrCreateLiveActivityChannel(id) + } + + channel.value?.let { content(it) } +} + +@Composable +fun produceStateIfNotNull( + initialValue: T, + key1: Any?, + producer: suspend ProduceStateScope.() -> Unit, +): State { + val result = remember(key1) { mutableStateOf(initialValue) } + if (result.value == null) { + LaunchedEffect(key1) { ProduceStateScopeImpl(result, coroutineContext).producer() } + } + return result +} + +class ProduceStateScopeImpl( + state: MutableState, + override val coroutineContext: CoroutineContext, +) : ProduceStateScope, + MutableState by state { + override suspend fun awaitDispose(onDispose: () -> Unit): Nothing { + try { + suspendCancellableCoroutine {} + } finally { + onDispose() } } - - channel?.let { content(it) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt index 04d59482be..70abe8becb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt @@ -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 @@ -30,6 +30,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -37,9 +39,11 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.elements.NoteDropDownMenu +import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.MessageSetCard import com.vitorpamplona.amethyst.ui.theme.StdStartPadding @@ -81,7 +85,7 @@ fun MessageSetCompose( scope.launch { routeFor( baseNote, - accountViewModel.userProfile(), + accountViewModel.account, )?.let { nav.nav(it) } } }, @@ -116,6 +120,11 @@ fun MessageSetCompose( @Composable fun MessageIconBox() { Box(Modifier.width(55.dp).padding(top = 5.dp, end = 5.dp)) { - MessageIcon(Modifier.size(16.dp).align(Alignment.TopEnd)) + Icon( + painter = painterRes(R.drawable.ic_dm, 4), + null, + modifier = Modifier.size(16.dp).align(Alignment.TopEnd), + tint = MaterialTheme.colorScheme.primary, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 2ed435160b..6caa3dff45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -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 @@ -43,7 +43,6 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember @@ -69,14 +68,15 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.CachedRichTextParser +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPicture import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius import com.vitorpamplona.amethyst.ui.components.CoreSecretMessage import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.authorRouteFor -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.authorRouteFor +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.elements.NoteDropDownMenu import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap @@ -134,7 +134,7 @@ fun MultiSetCompose( .background(backgroundColor.value) .combinedClickable( onClick = { - scope.launch { routeFor(baseNote, accountViewModel.userProfile())?.let { nav.nav(it) } } + scope.launch { routeFor(baseNote, accountViewModel.account)?.let { nav.nav(it) } } }, onLongClick = { popupExpanded.value = true }, ).padding( @@ -597,7 +597,7 @@ fun WatchUserMetadataAndFollowsAndRenderUserProfilePicture( author: User, accountViewModel: AccountViewModel, ) { - WatchUserMetadata(author) { baseUserPicture -> + WatchUserMetadata(author, accountViewModel) { baseUserPicture -> RobohashFallbackAsyncImage( robot = author.pubkeyHex, model = baseUserPicture, @@ -621,9 +621,10 @@ fun WatchUserMetadataAndFollowsAndRenderUserProfilePicture( @Composable private fun WatchUserMetadata( author: User, + accountViewModel: AccountViewModel, onNewMetadata: @Composable (String?) -> Unit, ) { - val userProfile by author.live().profilePictureChanges.observeAsState(author.profilePicture()) + val userProfile by observeUserPicture(author, accountViewModel) onNewMetadata(userProfile) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt index ed7ac1dc8c..e55fbd8800 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt @@ -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,7 +25,9 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.OpenInNew +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.Downloading +import androidx.compose.material.icons.filled.Report import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle @@ -35,33 +37,36 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.commons.hashtags.Tunestr import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNip05 +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserStatuses import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote -import com.vitorpamplona.amethyst.ui.note.LoadStatuses -import com.vitorpamplona.amethyst.ui.note.NIP05CheckingIcon -import com.vitorpamplona.amethyst.ui.note.NIP05FailedVerification -import com.vitorpamplona.amethyst.ui.note.NIP05VerifiedIcon import com.vitorpamplona.amethyst.ui.note.WatchAuthor +import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize import com.vitorpamplona.amethyst.ui.theme.Size15Modifier @@ -121,7 +126,7 @@ fun ObserveDisplayNip05Status( accountViewModel: AccountViewModel, nav: INav, ) { - WatchAuthor(baseNote = baseNote) { + WatchAuthor(baseNote = baseNote, accountViewModel) { ObserveDisplayNip05Status(it, columnModifier, accountViewModel, nav) } } @@ -133,24 +138,22 @@ fun ObserveDisplayNip05Status( accountViewModel: AccountViewModel, nav: INav, ) { - val nip05 by baseUser.live().nip05Changes.observeAsState(baseUser.nip05()) + val nip05 by observeUserNip05(baseUser, accountViewModel) + val statuses by observeUserStatuses(baseUser, accountViewModel) - LoadStatuses(baseUser, accountViewModel) { statuses -> - CrossfadeIfEnabled( - targetState = nip05, - modifier = columnModifier, - label = "ObserveDisplayNip05StatusCrossfade", - accountViewModel = accountViewModel, - ) { - VerifyAndDisplayNIP05OrStatusLine( - it, - statuses, - baseUser, - columnModifier, - accountViewModel, - nav, - ) - } + CrossfadeIfEnabled( + targetState = nip05, + modifier = columnModifier, + accountViewModel = accountViewModel, + ) { + VerifyAndDisplayNIP05OrStatusLine( + it, + statuses, + baseUser, + columnModifier, + accountViewModel, + nav, + ) } } @@ -193,7 +196,7 @@ fun ObserveRotateStatuses( accountViewModel: AccountViewModel, nav: INav, ) { - ObserveAllStatusesToAvoidSwitchigAllTheTime(statuses) + ObserveAllStatusesToAvoidSwitchigAllTheTime(statuses, accountViewModel) RotateStatuses( statuses, @@ -203,11 +206,13 @@ fun ObserveRotateStatuses( } @Composable -fun ObserveAllStatusesToAvoidSwitchigAllTheTime(statuses: ImmutableList) { - statuses - .map { - it.live().metadata.observeAsState() - } +fun ObserveAllStatusesToAvoidSwitchigAllTheTime( + statuses: ImmutableList, + accountViewModel: AccountViewModel, +) { + statuses.map { + EventFinderFilterAssemblerSubscription(it, accountViewModel) + } } @Composable @@ -247,7 +252,7 @@ fun DisplayStatus( accountViewModel: AccountViewModel, nav: INav, ) { - val noteState by addressableNote.live().metadata.observeAsState() + val noteState by observeNote(addressableNote, accountViewModel) val noteEvent = noteState?.note?.event as? StatusEvent ?: return DisplayStatus(noteEvent, accountViewModel, nav) @@ -307,7 +312,7 @@ fun DisplayStatusInner( onClick = { runCatching { uri.openUri(url.trim()) } }, ) { Icon( - imageVector = Icons.Default.OpenInNew, + imageVector = Icons.AutoMirrored.Filled.OpenInNew, null, modifier = Size15Modifier, tint = MaterialTheme.colorScheme.lessImportantLink, @@ -322,12 +327,12 @@ fun DisplayStatusInner( onClick = { routeFor( note, - accountViewModel.userProfile(), + accountViewModel.account, )?.let { nav.nav(it) } }, ) { Icon( - imageVector = Icons.Default.OpenInNew, + imageVector = Icons.AutoMirrored.Filled.OpenInNew, null, modifier = Size15Modifier, tint = MaterialTheme.colorScheme.lessImportantLink, @@ -344,12 +349,12 @@ fun DisplayStatusInner( onClick = { routeFor( it, - accountViewModel.userProfile(), + accountViewModel.account, )?.let { nav.nav(it) } }, ) { Icon( - imageVector = Icons.Default.OpenInNew, + imageVector = Icons.AutoMirrored.Filled.OpenInNew, null, modifier = Size15Modifier, tint = MaterialTheme.colorScheme.lessImportantLink, @@ -387,7 +392,7 @@ fun DisplayNIP05( ) } - NIP05VerifiedSymbol(nip05Verified, NIP05IconSize, accountViewModel) + NIP05VerifiedSymbol(nip05Verified, 1, NIP05IconSize, accountViewModel) ClickableTextPrimary( text = domain, @@ -402,14 +407,33 @@ fun DisplayNIP05( @Composable private fun NIP05VerifiedSymbol( nip05Verified: MutableState, + compositionSizeReference: Int, modifier: Modifier, accountViewModel: AccountViewModel, ) { CrossfadeIfEnabled(targetState = nip05Verified.value, accountViewModel = accountViewModel) { when (it) { - null -> NIP05CheckingIcon(modifier = modifier) - true -> NIP05VerifiedIcon(modifier = modifier) - false -> NIP05FailedVerification(modifier = modifier) + null -> + Icon( + imageVector = Icons.Default.Downloading, + contentDescription = stringRes(id = R.string.nip05_checking), + modifier = modifier, + tint = Color.Yellow, + ) + true -> + Icon( + painter = painterRes(R.drawable.nip_05, compositionSizeReference), + contentDescription = stringRes(id = R.string.nip05_verified), + modifier = modifier, + tint = Color.Unspecified, + ) + false -> + Icon( + imageVector = Icons.Default.Report, + contentDescription = stringRes(id = R.string.nip05_failed), + modifier = modifier, + tint = Color.Red, + ) } } } @@ -425,7 +449,7 @@ fun DisplayNip05ProfileStatus( if (nip05.split("@").size <= 2) { val nip05Verified = nip05VerificationAsAState(user.info!!, user.pubkeyHex, accountViewModel) Row(verticalAlignment = Alignment.CenterVertically) { - NIP05VerifiedSymbol(nip05Verified, Size16Modifier, accountViewModel) + NIP05VerifiedSymbol(nip05Verified, 2, Size16Modifier, accountViewModel) var domainPadStart = 5.dp val (user, domain) = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 97f38ff9bd..8c8576e6dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -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 @@ -38,7 +38,6 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -50,20 +49,21 @@ import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.lifecycle.distinctUntilChanged -import androidx.lifecycle.map import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.compose.produceCachedStateAsync import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannelPicture +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEdits +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.layouts.GenericRepostLayout -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.DisplayZapSplits import com.vitorpamplona.amethyst.ui.note.elements.BoostedMark import com.vitorpamplona.amethyst.ui.note.elements.DisplayEditStatus @@ -78,11 +78,17 @@ import com.vitorpamplona.amethyst.ui.note.elements.Reward import com.vitorpamplona.amethyst.ui.note.elements.ShowForkInformation import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay +import com.vitorpamplona.amethyst.ui.note.types.DisplayBlockedRelayList +import com.vitorpamplona.amethyst.ui.note.types.DisplayBroadcastRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayDMRelayList +import com.vitorpamplona.amethyst.ui.note.types.DisplayFollowList +import com.vitorpamplona.amethyst.ui.note.types.DisplayIndexerRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayNIP65RelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayPeopleList +import com.vitorpamplona.amethyst.ui.note.types.DisplayProxyRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayRelaySet import com.vitorpamplona.amethyst.ui.note.types.DisplaySearchRelayList +import com.vitorpamplona.amethyst.ui.note.types.DisplayTrustedRelayList import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.note.types.EmptyState import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay @@ -113,16 +119,18 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent import com.vitorpamplona.amethyst.ui.note.types.RenderPoll import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval import com.vitorpamplona.amethyst.ui.note.types.RenderPrivateMessage +import com.vitorpamplona.amethyst.ui.note.types.RenderPublicMessage import com.vitorpamplona.amethyst.ui.note.types.RenderReaction import com.vitorpamplona.amethyst.ui.note.types.RenderReport import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent import com.vitorpamplona.amethyst.ui.note.types.RenderTorrent import com.vitorpamplona.amethyst.ui.note.types.RenderTorrentComment +import com.vitorpamplona.amethyst.ui.note.types.RenderVoiceTrack import com.vitorpamplona.amethyst.ui.note.types.RenderWikiContent import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.RenderChannelHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.RenderPublicChatChannelHeader import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.Font12SP @@ -132,7 +140,6 @@ import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import com.vitorpamplona.amethyst.ui.theme.RowColSpacing10dp import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp import com.vitorpamplona.amethyst.ui.theme.Size25dp -import com.vitorpamplona.amethyst.ui.theme.Size30Modifier import com.vitorpamplona.amethyst.ui.theme.Size34dp import com.vitorpamplona.amethyst.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.ui.theme.Size55dp @@ -155,9 +162,10 @@ import com.vitorpamplona.quartz.experimental.forks.isAFork import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableKind -import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash +import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -181,9 +189,15 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip37Drafts.DraftEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent -import com.vitorpamplona.quartz.nip51Lists.RelaySetEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent @@ -203,6 +217,8 @@ import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent +import kotlinx.coroutines.delay @Composable fun NoteCompose( @@ -266,17 +282,24 @@ fun AcceptableNote( nav: INav, ) { if (isQuotedNote || isBoostedNote) { - when (baseNote.event) { - is ChannelCreateEvent, - is ChannelMetadataEvent, - -> - RenderChannelHeader( - channelNote = baseNote, - showVideo = !makeItShort, + val noteEvent = baseNote.event + when (noteEvent) { + is ChannelCreateEvent -> + RenderPublicChatChannelHeader( + channelId = noteEvent.id, sendToChannel = true, accountViewModel = accountViewModel, nav = nav, ) + is ChannelMetadataEvent -> + noteEvent.channelId()?.let { + RenderPublicChatChannelHeader( + channelId = it, + sendToChannel = true, + accountViewModel = accountViewModel, + nav = nav, + ) + } is CommunityDefinitionEvent -> (baseNote as? AddressableNote)?.let { RenderCommunity( @@ -285,7 +308,7 @@ fun AcceptableNote( nav = nav, ) } - is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote) + is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote, accountViewModel) else -> LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup -> CheckNewAndRenderNote( @@ -306,17 +329,23 @@ fun AcceptableNote( } } } else { - when (baseNote.event) { - is ChannelCreateEvent, - is ChannelMetadataEvent, - -> - RenderChannelHeader( - channelNote = baseNote, - showVideo = !makeItShort, + when (val noteEvent = baseNote.event) { + is ChannelCreateEvent -> + RenderPublicChatChannelHeader( + channelId = noteEvent.id, sendToChannel = true, accountViewModel = accountViewModel, nav = nav, ) + is ChannelMetadataEvent -> + noteEvent.channelId()?.let { + RenderPublicChatChannelHeader( + channelId = it, + sendToChannel = true, + accountViewModel = accountViewModel, + nav = nav, + ) + } is CommunityDefinitionEvent -> (baseNote as? AddressableNote)?.let { RenderCommunity( @@ -325,7 +354,7 @@ fun AcceptableNote( nav = nav, ) } - is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote) + is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote, accountViewModel) else -> LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup -> CheckNewAndRenderNote( @@ -357,25 +386,33 @@ fun calculateBackgroundColor( ): MutableState { val defaultBackgroundColor = MaterialTheme.colorScheme.background val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor - return remember(createdAt) { - mutableStateOf( - if (routeForLastRead != null) { - val isNew = accountViewModel.loadAndMarkAsRead(routeForLastRead, createdAt) + val bgColor = + remember(createdAt) { + mutableStateOf( + if (routeForLastRead != null) { + val isNew = accountViewModel.loadAndMarkAsRead(routeForLastRead, createdAt) - if (isNew) { - if (parentBackgroundColor != null) { - newItemColor.compositeOver(parentBackgroundColor.value) + if (isNew) { + if (parentBackgroundColor != null) { + newItemColor.compositeOver(parentBackgroundColor.value) + } else { + newItemColor.compositeOver(defaultBackgroundColor) + } } else { - newItemColor.compositeOver(defaultBackgroundColor) + parentBackgroundColor?.value ?: Color.Transparent } } else { parentBackgroundColor?.value ?: Color.Transparent - } - } else { - parentBackgroundColor?.value ?: Color.Transparent - }, - ) + }, + ) + } + + LaunchedEffect(createdAt) { + delay(5000) + bgColor.value = parentBackgroundColor?.value ?: Color.Transparent } + + return bgColor } @Composable @@ -447,7 +484,7 @@ fun ClickableNote( } else { baseNote } - routeFor(redirectToNote, accountViewModel.userProfile())?.let { nav.nav(it) } + routeFor(redirectToNote, accountViewModel.account)?.let { nav.nav(it) } }, onLongClick = showPopup, ).background(backgroundColor.value) @@ -569,7 +606,7 @@ fun NoteBody( } if (baseNote.event !is RepostEvent && baseNote.event !is GenericRepostEvent) { - Spacer(modifier = Modifier.height(3.dp)) + Spacer(modifier = Modifier.height(4.dp)) } RenderNoteRow( @@ -620,10 +657,16 @@ private fun RenderNoteRow( is BadgeAwardEvent -> RenderBadgeAward(baseNote, backgroundColor, accountViewModel, nav) is FhirResourceEvent -> RenderFhirResource(baseNote, accountViewModel, nav) is PeopleListEvent -> DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav) + is FollowListEvent -> DisplayFollowList(baseNote, backgroundColor, accountViewModel, nav) is RelaySetEvent -> DisplayRelaySet(baseNote, backgroundColor, accountViewModel, nav) is ChatMessageRelayListEvent -> DisplayDMRelayList(baseNote, backgroundColor, accountViewModel, nav) is AdvertisedRelayListEvent -> DisplayNIP65RelayList(baseNote, backgroundColor, accountViewModel, nav) is SearchRelayListEvent -> DisplaySearchRelayList(baseNote, backgroundColor, accountViewModel, nav) + is BlockedRelayListEvent -> DisplayBlockedRelayList(baseNote, backgroundColor, accountViewModel, nav) + is TrustedRelayListEvent -> DisplayTrustedRelayList(baseNote, backgroundColor, accountViewModel, nav) + is IndexerRelayListEvent -> DisplayIndexerRelayList(baseNote, backgroundColor, accountViewModel, nav) + is ProxyRelayListEvent -> DisplayProxyRelayList(baseNote, backgroundColor, accountViewModel, nav) + is BroadcastRelayListEvent -> DisplayBroadcastRelayList(baseNote, backgroundColor, accountViewModel, nav) is PinListEvent -> RenderPinListEvent(baseNote, backgroundColor, accountViewModel, nav) is EmojiPackEvent -> RenderEmojiPack(baseNote, true, backgroundColor, accountViewModel) is LiveActivitiesEvent -> RenderLiveActivityEvent(baseNote, accountViewModel, nav) @@ -751,7 +794,7 @@ private fun RenderNoteRow( is VideoHorizontalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav) is VideoVerticalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav) is PictureEvent -> PictureDisplay(baseNote, true, ContentScale.FillWidth, PaddingValues(vertical = 5.dp), backgroundColor, accountViewModel, nav) - + is BaseVoiceEvent -> RenderVoiceTrack(baseNote, ContentScale.FillWidth, accountViewModel, nav) is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, true, ContentScale.FillWidth, accountViewModel) is CommunityPostApprovalEvent -> { RenderPostApproval( @@ -826,7 +869,16 @@ private fun RenderNoteRow( accountViewModel, nav, ) - + is PublicMessageEvent -> + RenderPublicMessage( + baseNote, + makeItShort, + canPreview, + quotesLeft, + backgroundColor, + accountViewModel, + nav, + ) else -> { RenderTextEvent( baseNote, @@ -849,14 +901,14 @@ fun ObserveDraftEvent( accountViewModel: AccountViewModel, render: @Composable (Note) -> Unit, ) { - val noteState by note.live().metadata.observeAsState() + val noteEvent by observeNoteEvent(note, accountViewModel) - val noteEvent = noteState?.note?.event as? DraftEvent ?: return + noteEvent?.let { + val innerNote by produceCachedStateAsync(cache = accountViewModel.draftNoteCache, key = it) - val innerNote = produceCachedStateAsync(cache = accountViewModel.draftNoteCache, key = noteEvent) - - innerNote.value?.let { - render(it) + innerNote?.let { + render(it) + } } } @@ -919,7 +971,7 @@ fun getGradient(backgroundColor: MutableState): Brush = colors = listOf( backgroundColor.value.copy(alpha = 0f), - backgroundColor.value, + backgroundColor.value.copy(alpha = 1f), ), ) @@ -963,10 +1015,10 @@ fun SecondUserInfoRow( ObserveDisplayNip05Status(noteAuthor, remember(noteEvent) { Modifier.weight(1f) }, accountViewModel, nav) } - val geo = remember(noteEvent) { noteEvent.getGeoHash() } + val geo = remember(noteEvent) { noteEvent.geoHashOrScope() } if (geo != null) { Spacer(StdHorzSpacer) - DisplayLocation(geo, nav) + DisplayLocation(geo, accountViewModel, nav) } val baseReward = remember(noteEvent) { noteEvent.bountyBaseReward()?.let { Reward(it) } } @@ -1044,7 +1096,7 @@ fun FirstUserInfoRow( val textColor = if (isRepost) MaterialTheme.colorScheme.grayText else Color.Unspecified if (showAuthorPicture) { - NoteAuthorPicture(baseNote, nav, accountViewModel, Size25dp) + NoteAuthorPicture(baseNote, Size25dp, accountViewModel = accountViewModel, nav = nav) Spacer(HalfPadding) NoteUsernameDisplay(baseNote, Modifier.weight(1f), textColor = textColor, accountViewModel = accountViewModel) } else { @@ -1109,7 +1161,7 @@ fun observeEdits( ) } - val updatedNote by baseNote.live().innerModifications.observeAsState() + val updatedNote by observeNoteEdits(baseNote, accountViewModel) LaunchedEffect(key1 = updatedNote) { updatedNote?.note?.let { @@ -1155,25 +1207,25 @@ private fun RenderAuthorImages( nav: INav, accountViewModel: AccountViewModel, ) { - if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) { + val noteEvent = baseNote.event + if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) { val baseRepost = baseNote.replyTo?.lastOrNull() if (baseRepost != null) { RepostNoteAuthorPicture(baseNote, baseRepost, accountViewModel, nav) } else { - NoteAuthorPicture(baseNote, nav, accountViewModel, Size55dp) + NoteAuthorPicture(baseNote, Size55dp, accountViewModel = accountViewModel, nav = nav) } } else { - NoteAuthorPicture(baseNote, nav, accountViewModel, Size55dp) + NoteAuthorPicture(baseNote, Size55dp, accountViewModel = accountViewModel, nav = nav) } - if (baseNote.event is ChannelMessageEvent) { - val baseChannelHex = remember(baseNote) { baseNote.channelHex() } + if (noteEvent is ChannelMessageEvent) { + val baseChannelHex = noteEvent.channelId() if (baseChannelHex != null) { - LoadChannel(baseChannelHex, accountViewModel) { channel -> + LoadPublicChatChannel(baseChannelHex, accountViewModel) { channel -> ChannelNotePicture( channel, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, - loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, + accountViewModel, ) } } @@ -1182,26 +1234,19 @@ private fun RenderAuthorImages( @Composable private fun ChannelNotePicture( - baseChannel: Channel, - loadProfilePicture: Boolean, - loadRobohash: Boolean, + baseChannel: PublicChatChannel, + accountViewModel: AccountViewModel, ) { - val model by - baseChannel.live - .map { it.channel.profilePicture() } - .distinctUntilChanged() - .observeAsState() + val model by observeChannelPicture(baseChannel, accountViewModel) - Box(Size30Modifier) { - RobohashFallbackAsyncImage( - robot = baseChannel.idHex, - model = model, - contentDescription = stringRes(R.string.group_picture), - modifier = MaterialTheme.colorScheme.channelNotePictureModifier, - loadProfilePicture = loadProfilePicture, - loadRobohash = loadRobohash, - ) - } + RobohashFallbackAsyncImage( + robot = baseChannel.idHex, + model = model, + contentDescription = stringRes(R.string.group_picture), + modifier = MaterialTheme.colorScheme.channelNotePictureModifier, + loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, + ) } @Composable @@ -1215,17 +1260,17 @@ private fun RepostNoteAuthorPicture( baseAuthorPicture = { NoteAuthorPicture( baseNote = baseNote, - nav = nav, - accountViewModel = accountViewModel, size = Size34dp, + accountViewModel = accountViewModel, + nav = nav, ) }, repostAuthorPicture = { NoteAuthorPicture( baseNote = baseRepost, - nav = nav, - accountViewModel = accountViewModel, size = Size34dp, + accountViewModel = accountViewModel, + nav = nav, ) }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index be90c1bdfe..84339a6af8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -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 @@ -59,7 +59,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -72,33 +71,30 @@ import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup -import androidx.core.content.ContextCompat import androidx.core.graphics.ColorUtils import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.components.SelectTextDialog -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.WarningColor +import com.vitorpamplona.amethyst.ui.theme.LightRedColor import com.vitorpamplona.amethyst.ui.theme.isLight import com.vitorpamplona.amethyst.ui.theme.secondaryButtonBackground import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward -import com.vitorpamplona.quartz.nip19Bech32.toNAddr -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import kotlinx.coroutines.launch @@ -192,7 +188,6 @@ fun NoteQuickActionMenu( accountViewModel: AccountViewModel, nav: INav, ) { - val showSelectTextDialog = remember { mutableStateOf(false) } val showDeleteAlertDialog = remember { mutableStateOf(false) } val showBlockAlertDialog = remember { mutableStateOf(false) } val showReportDialog = remember { mutableStateOf(false) } @@ -207,19 +202,6 @@ fun NoteQuickActionMenu( onWantsToEditDraft, ) - if (showSelectTextDialog.value) { - val decryptedNote = remember { mutableStateOf(null) } - - LaunchedEffect(key1 = Unit) { accountViewModel.decrypt(note) { decryptedNote.value = it } } - - decryptedNote.value?.let { - SelectTextDialog(it) { - showSelectTextDialog.value = false - decryptedNote.value = null - } - } - } - if (showDeleteAlertDialog.value) { DeleteAlertDialog(note, accountViewModel) { showDeleteAlertDialog.value = false @@ -414,7 +396,7 @@ private fun RenderMainPopup( sendIntent, stringRes(context, R.string.quick_action_share), ) - ContextCompat.startActivity(context, shareIntent, null) + context.startActivity(shareIntent) onDismiss() } } @@ -435,169 +417,6 @@ private fun RenderMainPopup( } } -@Composable -private fun RenderDeleteFromGalleryPopup( - accountViewModel: AccountViewModel, - note: Note, - showDeleteAlertDialog: MutableState, - onDismiss: () -> Unit, -) { - val context = LocalContext.current - val primaryLight = lightenColor(MaterialTheme.colorScheme.primary, 0.1f) - val cardShape = RoundedCornerShape(5.dp) - val clipboardManager = LocalClipboardManager.current - val scope = rememberCoroutineScope() - - val backgroundColor = - if (MaterialTheme.colorScheme.isLight) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.secondaryButtonBackground - } - - val showToast = { stringRes: Int -> - scope.launch { - Toast - .makeText( - context, - stringRes(context, stringRes), - Toast.LENGTH_SHORT, - ).show() - } - } - - val isOwnNote = accountViewModel.isLoggedUser(note.author) - val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author) - - Popup(onDismissRequest = onDismiss, alignment = Alignment.Center) { - Card( - modifier = Modifier.shadow(elevation = 6.dp, shape = cardShape), - shape = cardShape, - colors = CardDefaults.cardColors(containerColor = backgroundColor), - ) { - Column(modifier = Modifier.width(IntrinsicSize.Min)) { - Row(modifier = Modifier.height(IntrinsicSize.Min)) { - NoteQuickActionItem( - icon = Icons.Default.ContentCopy, - label = stringRes(R.string.quick_action_copy_text), - ) { - accountViewModel.decrypt(note) { - clipboardManager.setText(AnnotatedString(it)) - showToast(R.string.copied_note_text_to_clipboard) - } - - onDismiss() - } - VerticalDivider(color = primaryLight) - NoteQuickActionItem( - Icons.Default.AlternateEmail, - stringRes(R.string.quick_action_copy_user_id), - ) { - note.author?.let { - scope.launch { - clipboardManager.setText(AnnotatedString(it.toNostrUri())) - showToast(R.string.copied_user_id_to_clipboard) - onDismiss() - } - } - } - VerticalDivider(color = primaryLight) - NoteQuickActionItem( - Icons.Default.FormatQuote, - stringRes(R.string.quick_action_copy_note_id), - ) { - scope.launch { - clipboardManager.setText(AnnotatedString(note.toNostrUri())) - showToast(R.string.copied_note_id_to_clipboard) - onDismiss() - } - } - } - HorizontalDivider( - color = primaryLight, - ) - Row(modifier = Modifier.height(IntrinsicSize.Min)) { - if (isOwnNote) { - NoteQuickActionItem( - Icons.Default.Delete, - stringRes(R.string.quick_action_delete), - ) { - if (accountViewModel.account.settings.hideDeleteRequestDialog) { - accountViewModel.delete(note) - onDismiss() - } else { - showDeleteAlertDialog.value = true - } - } - } else if (isFollowingUser) { - NoteQuickActionItem( - Icons.Default.PersonRemove, - stringRes(R.string.quick_action_unfollow), - ) { - accountViewModel.unfollow(note.author!!) - onDismiss() - } - } else { - NoteQuickActionItem( - Icons.Default.PersonAdd, - stringRes(R.string.quick_action_follow), - ) { - accountViewModel.follow(note.author!!) - onDismiss() - } - } - - VerticalDivider(color = primaryLight) - NoteQuickActionItem( - icon = ImageVector.vectorResource(id = R.drawable.relays), - label = stringRes(R.string.broadcast), - ) { - accountViewModel.broadcast(note) - // showSelectTextDialog = true - onDismiss() - } - VerticalDivider(color = primaryLight) - if (isOwnNote && note.isDraft()) { - NoteQuickActionItem( - Icons.Default.Edit, - stringRes(R.string.edit_draft), - ) { - onDismiss() - } - } else { - NoteQuickActionItem( - icon = Icons.Default.Share, - label = stringRes(R.string.quick_action_share), - ) { - val sendIntent = - Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra( - Intent.EXTRA_TEXT, - externalLinkForNote(note), - ) - putExtra( - Intent.EXTRA_TITLE, - stringRes(context, R.string.quick_action_share_browser_link), - ) - } - - val shareIntent = - Intent.createChooser( - sendIntent, - stringRes(context, R.string.quick_action_share), - ) - ContextCompat.startActivity(context, shareIntent, null) - onDismiss() - } - } - } - } - } - } -} - @Composable fun NoteQuickActionItem( icon: ImageVector, @@ -661,7 +480,7 @@ private fun BlockAlertDialog( buttonText = stringRes(R.string.quick_action_block_dialog_btn), buttonColors = ButtonDefaults.buttonColors( - containerColor = WarningColor, + containerColor = LightRedColor, contentColor = Color.White, ), onClickDoOnce = { @@ -709,6 +528,7 @@ fun QuickActionAlertDialog( title: String, textContent: String, buttonIconResource: Int, + buttonIconReference: Int, buttonText: String, buttonColors: ButtonColors = ButtonDefaults.buttonColors(), onClickDoOnce: () -> Unit, @@ -720,7 +540,7 @@ fun QuickActionAlertDialog( textContent = textContent, icon = { Icon( - painter = painterResource(buttonIconResource), + painter = painterRes(buttonIconResource, buttonIconReference), contentDescription = null, ) }, @@ -807,6 +627,7 @@ fun QuickActionAlertDialogOneButton( title: String, textContent: String, buttonIconResource: Int, + buttonIconReference: Int, buttonText: String, buttonColors: ButtonColors = ButtonDefaults.buttonColors(), onClickDoOnce: () -> Unit, @@ -817,7 +638,7 @@ fun QuickActionAlertDialogOneButton( textContent = textContent, icon = { Icon( - painter = painterResource(buttonIconResource), + painter = painterRes(buttonIconResource, buttonIconReference), contentDescription = null, ) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt index 725f74977b..c448e01791 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -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 @@ -51,7 +51,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -75,11 +75,12 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.ZapPaymentHandler +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.toasts.StringToastMsg -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeToMessage +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockVitorAccountViewModel @@ -152,9 +153,9 @@ fun PollNotePreview() { runBlocking { withContext(Dispatchers.IO) { - LocalCache.justConsume(event, null) - LocalCache.consume(zapVote.zapRequest!!) - LocalCache.consume(zapVote, null) + LocalCache.justConsume(event, null, false) + LocalCache.consume(zapVote.zapRequest!!, null, false) + LocalCache.justConsume(zapVote, null, false) baseNote = LocalCache.getOrCreateNote("6ff9bc13d27490f6e3953325260bd996901a143de89886a0608c39e7d0160a72") } } @@ -206,7 +207,7 @@ fun PollNotePreview2() { runBlocking { withContext(Dispatchers.IO) { - LocalCache.justConsume(event, null) + LocalCache.justConsume(event, null, false) baseNote = LocalCache.getOrCreateNote("3064bf97800a4b04b612fc0fd498936eae75fffbdca5bbd09d19a6dc598530ab") } } @@ -242,7 +243,8 @@ fun PollNote( ) { val pollViewModel: PollNoteViewModel = viewModel(key = "PollNoteViewModel${baseNote.idHex}") - pollViewModel.load(accountViewModel.account, baseNote) + pollViewModel.init(accountViewModel.account) + pollViewModel.load(baseNote) PollNote( baseNote = baseNote, @@ -263,7 +265,7 @@ fun PollNote( accountViewModel: AccountViewModel, nav: INav, ) { - WatchZapsAndUpdateTallies(baseNote, pollViewModel) + WatchZapsAndUpdateTallies(baseNote, pollViewModel, accountViewModel) pollViewModel.tallies.forEach { option -> OptionNote( @@ -282,8 +284,9 @@ fun PollNote( private fun WatchZapsAndUpdateTallies( baseNote: Note, pollViewModel: PollNoteViewModel, + accountViewModel: AccountViewModel, ) { - val zapsState by baseNote.live().zaps.observeAsState() + val zapsState by observeNoteZaps(baseNote, accountViewModel) LaunchedEffect(key1 = zapsState) { pollViewModel.refreshTallies() } } @@ -513,7 +516,7 @@ fun ZapVote( ) } - var zappingProgress by remember { mutableStateOf(0f) } + var zappingProgress by remember { mutableFloatStateOf(0f) } var showErrorMessageDialog by remember { mutableStateOf(null) } val context = LocalContext.current @@ -658,7 +661,7 @@ fun ZapVote( } else { Spacer(Modifier.width(3.dp)) CircularProgressIndicator( - progress = zappingProgress, + progress = { zappingProgress }, modifier = Modifier.size(14.dp), strokeWidth = 2.dp, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt index f9476e789d..3692520687 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt @@ -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,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.note import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -42,14 +43,14 @@ data class PollOption( val option: Int, val descriptor: String, var zappedValue: MutableState = mutableStateOf(BigDecimal.ZERO), - var tally: MutableState = mutableStateOf(0f), + var tally: MutableState = mutableFloatStateOf(0f), var consensusThreadhold: MutableState = mutableStateOf(false), var zappedByLoggedIn: MutableState = mutableStateOf(false), ) @Stable class PollNoteViewModel : ViewModel() { - private var account: Account? = null + private lateinit var account: Account private var pollNote: Note? = null private var pollEvent: PollNoteEvent? = null @@ -68,12 +69,12 @@ class PollNoteViewModel : ViewModel() { var canZap = mutableStateOf(false) var tallies: List = emptyList() - fun load( - acc: Account, - note: Note?, - ) { - if (acc != account || pollNote != note) { - account = acc + fun init(acc: Account) { + account = acc + } + + fun load(note: Note?) { + if (pollNote != note) { pollNote = note pollEvent = pollNote?.event as PollNoteEvent pollOptions = pollEvent?.pollOptions() @@ -220,7 +221,10 @@ class PollNoteViewModel : ViewModel() { ): Boolean = pollNote!!.zaps.any { val zapEvent = it.value?.event as? LnZapEvent - val privateZapAuthor = (it.key.event as? LnZapRequestEvent)?.cachedPrivateZap() + val privateZapAuthor = + (it.key.event as? LnZapRequestEvent)?.let { + account.privateZapsDecryptionCache.cachedPrivateZap(it) + } zapEvent?.zappedPollOption() == option && (it.key.author?.pubkeyHex == user.pubkeyHex || privateZapAuthor?.pubKey == user.pubkeyHex) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PubKeyFormatter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PubKeyFormatter.kt index 29be5a6779..9e18f7e2f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PubKeyFormatter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PubKeyFormatter.kt @@ -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,11 +23,11 @@ package com.vitorpamplona.amethyst.ui.note import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey -fun ByteArray.toShortenHex(): String = toHexKey().toShortenHex() +fun ByteArray.toHexShortDisplay(): String = toHexKey().toShortDisplay() -fun String.toShortenHex(): String { +fun String.toShortDisplay(): String { if (length <= 16) return this - return replaceRange(8, length - 8, ":") + return replaceRange(8, length - 8, "…") } -fun HexKey.toDisplayHexKey(): String = this.toShortenHex() +fun HexKey.toDisplayHexKey(): String = this.toShortDisplay() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 047dc83e1f..485d665cd9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -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 @@ -65,7 +65,6 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState @@ -93,27 +92,32 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties -import androidx.core.content.ContextCompat -import androidx.lifecycle.LiveData -import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.distinctUntilChanged -import androidx.lifecycle.map +import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource.user import com.vitorpamplona.amethyst.service.ZapPaymentHandler +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReactionCount +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReactions +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReferences +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplyCount +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteRepostCount +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReposts +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteRepostsBy +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled +import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route -import com.vitorpamplona.amethyst.ui.navigation.routeToMessage +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.routeReplyTo import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -150,11 +154,11 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.reactionBox import com.vitorpamplona.amethyst.ui.theme.ripple24dp import com.vitorpamplona.amethyst.ui.theme.selectedReactionBoxModifier -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount +import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf @@ -201,7 +205,7 @@ private fun InnerReactionRow( showReactionDetail = showReactionDetail, addPadding = addPadding, one = { - WatchReactionsZapsBoostsAndDisplayIfExists(baseNote) { + WatchReactionsZapsBoostsAndDisplayIfExists(baseNote, accountViewModel) { RenderShowIndividualReactionsButton(wantsToSeeReactions, accountViewModel) } }, @@ -270,7 +274,7 @@ fun ShareReaction( sendIntent, stringRes(context, R.string.quick_action_share), ) - ContextCompat.startActivity(context, shareIntent, null) + context.startActivity(shareIntent) }, ) { ShareIcon(barChartModifier, grayTint) @@ -344,16 +348,15 @@ fun RenderZapRaiser( details: Boolean, accountViewModel: AccountViewModel, ) { - val zapsState by baseNote.live().zaps.observeAsState() + val zapsState by observeNoteZaps(baseNote, accountViewModel) var zapraiserStatus by remember { mutableStateOf(ZapraiserStatus(0F, "$zapraiserAmount")) } LaunchedEffect(key1 = zapsState) { zapsState?.note?.let { - accountViewModel.calculateZapraiser(baseNote) { newStatus -> - if (zapraiserStatus != newStatus) { - zapraiserStatus = newStatus - } + val newStatus = accountViewModel.calculateZapraiser(baseNote) + if (zapraiserStatus != newStatus) { + zapraiserStatus = newStatus } } } @@ -392,45 +395,16 @@ fun RenderZapRaiser( @Composable private fun WatchReactionsZapsBoostsAndDisplayIfExists( baseNote: Note, + accountViewModel: AccountViewModel, content: @Composable () -> Unit, ) { - val hasReactions by - baseNote - .live() - .hasReactions - .observeAsState( - baseNote.zaps.isNotEmpty() || - baseNote.boosts.isNotEmpty() || - baseNote.reactions.isNotEmpty(), - ) + val hasReactions by observeNoteReferences(baseNote, accountViewModel) if (hasReactions) { content() } } -fun LiveData.combineWith( - liveData1: LiveData, - block: (T?, K?) -> R, -): LiveData { - val result = MediatorLiveData() - result.addSource(this) { result.value = block(this.value, liveData1.value) } - result.addSource(liveData1) { result.value = block(this.value, liveData1.value) } - return result -} - -fun LiveData.combineWith( - liveData1: LiveData, - liveData2: LiveData

, - block: (T?, K?, P?) -> R, -): LiveData { - val result = MediatorLiveData() - result.addSource(this) { result.value = block(this.value, liveData1.value, liveData2.value) } - result.addSource(liveData1) { result.value = block(this.value, liveData1.value, liveData2.value) } - result.addSource(liveData2) { result.value = block(this.value, liveData1.value, liveData2.value) } - return result -} - @Composable private fun RenderShowIndividualReactionsButton( wantsToSeeReactions: MutableState, @@ -463,15 +437,7 @@ private fun ReactionDetailGallery( val defaultBackgroundColor = MaterialTheme.colorScheme.background val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } - val hasReactions by - baseNote - .live() - .hasReactions - .observeAsState( - baseNote.zaps.isNotEmpty() || - baseNote.boosts.isNotEmpty() || - baseNote.reactions.isNotEmpty(), - ) + val hasReactions by observeNoteReferences(baseNote, accountViewModel) if (hasReactions) { Row( @@ -493,7 +459,7 @@ private fun WatchBoostsAndRenderGallery( nav: INav, accountViewModel: AccountViewModel, ) { - val boostsEvents by baseNote.live().boosts.observeAsState() + val boostsEvents by observeNoteReposts(baseNote, accountViewModel) boostsEvents?.let { if (it.note.boosts.isNotEmpty()) { @@ -512,7 +478,7 @@ private fun WatchReactionsAndRenderGallery( nav: INav, accountViewModel: AccountViewModel, ) { - val reactionsState by baseNote.live().reactions.observeAsState() + val reactionsState by observeNoteReactions(baseNote, accountViewModel) val reactionEvents = reactionsState?.note?.reactions ?: return if (reactionEvents.isNotEmpty()) { @@ -535,7 +501,7 @@ private fun WatchZapAndRenderGallery( nav: INav, accountViewModel: AccountViewModel, ) { - val zapsState by baseNote.live().zaps.observeAsState() + val zapsState by observeNoteZaps(baseNote, accountViewModel) var zapEvents by remember(zapsState) { @@ -588,8 +554,7 @@ private fun BoostWithDialog( val forkEvent = baseNote.event val replyTo = if (forkEvent is BaseThreadedEvent) { - val hex = forkEvent.replyingTo() - baseNote.replyTo?.filter { it.event?.id == hex }?.firstOrNull() + baseNote.replyTo?.firstOrNull { it.event?.id == forkEvent.replyingTo() } } else { null } @@ -616,39 +581,40 @@ private fun ReplyReactionWithDialog( accountViewModel: AccountViewModel, nav: INav, ) { - ReplyReaction(baseNote, grayTint, accountViewModel) { - val noteEvent = baseNote.event - if (noteEvent is PrivateDmEvent) { - nav.nav { - routeToMessage( - room = noteEvent.chatroomKey(accountViewModel.userProfile().pubkeyHex), - draftMessage = null, - replyId = noteEvent.id, - draftId = null, - accountViewModel = accountViewModel, - ) - } - } else if (noteEvent is ChatroomKeyable) { - nav.nav { - routeToMessage( - room = noteEvent.chatroomKey(accountViewModel.userProfile().pubkeyHex), - draftMessage = null, - replyId = noteEvent.id, - draftId = null, - accountViewModel = accountViewModel, - ) - } - } else { - nav.nav { - Route.NewPost( - baseReplyTo = baseNote.idHex, - quote = null, - ) - } + if (baseNote.event is BaseVoiceEvent) { + ReplyViaVoiceReaction(baseNote, grayTint, accountViewModel) + } else { + ReplyReaction(baseNote, grayTint, accountViewModel) { + nav.nav { routeReplyTo(baseNote, accountViewModel.account) } } } } +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun ReplyViaVoiceReaction( + baseNote: Note, + grayTint: Color, + accountViewModel: AccountViewModel, + showCounter: Boolean = true, + iconSizeModifier: Modifier = Size19Modifier, +) { + val context = LocalContext.current + + RecordAudioBox( + modifier = iconSizeModifier, + onRecordTaken = { audio -> + accountViewModel.sendVoiceReply(baseNote, audio, context) + }, + ) { + VoiceReplyIcon(iconSizeModifier, grayTint) + } + + if (showCounter) { + ReplyCounter(baseNote, grayTint, accountViewModel) + } +} + @Composable fun ReplyReaction( baseNote: Note, @@ -692,7 +658,7 @@ fun ReplyCounter( textColor: Color, accountViewModel: AccountViewModel, ) { - val repliesState by baseNote.live().replyCount.observeAsState(baseNote.replies.size) + val repliesState by observeNoteReplyCount(baseNote, accountViewModel) SlidingAnimationCount(repliesState, textColor, accountViewModel) } @@ -827,16 +793,7 @@ fun ObserveBoostIcon( accountViewModel: AccountViewModel, inner: @Composable (Boolean) -> Unit, ) { - val hasBoosted by - remember(baseNote) { - baseNote - .live() - .boosts - .map { it.note.isBoostedBy(accountViewModel.userProfile()) } - .distinctUntilChanged() - }.observeAsState( - baseNote.isBoostedBy(accountViewModel.userProfile()), - ) + val hasBoosted by observeNoteRepostsBy(baseNote, accountViewModel.userProfile(), accountViewModel) inner(hasBoosted) } @@ -847,7 +804,7 @@ fun BoostText( grayTint: Color, accountViewModel: AccountViewModel, ) { - val boostState by baseNote.live().boostCount.observeAsState(baseNote.boosts.size) + val boostState by observeNoteRepostCount(baseNote, accountViewModel) SlidingAnimationCount(boostState, grayTint, accountViewModel) } @@ -908,7 +865,7 @@ fun LikeReaction( } } - ObserveLikeText(baseNote) { reactionCount -> SlidingAnimationCount(reactionCount, grayTint, accountViewModel) } + ObserveLikeText(baseNote, accountViewModel) { reactionCount -> SlidingAnimationCount(reactionCount, grayTint, accountViewModel) } } @Composable @@ -917,7 +874,7 @@ fun ObserveLikeIcon( accountViewModel: AccountViewModel, inner: @Composable (String?) -> Unit, ) { - val reactionsState by baseNote.live().reactions.observeAsState() + val reactionsState by observeNoteReactions(baseNote, accountViewModel) @Suppress("ProduceStateDoesNotAssignValue") val reactionType by @@ -972,9 +929,10 @@ private fun RenderReactionType( @Composable fun ObserveLikeText( baseNote: Note, + accountViewModel: AccountViewModel, inner: @Composable (Int) -> Unit, ) { - val reactionCount by baseNote.live().reactionCount.observeAsState(0) + val reactionCount by observeNoteReactionCount(baseNote, accountViewModel) inner(reactionCount) } @@ -1142,13 +1100,14 @@ fun ZapReaction( if (zappingProgress > 0.00001 && zappingProgress < 0.99999) { Spacer(ModifierWidth3dp) + val animatedProgress by animateFloatAsState( + targetValue = zappingProgress, + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, + label = "ZapIconIndicator", + ) + CircularProgressIndicator( - progress = - animateFloatAsState( - targetValue = zappingProgress, - animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, - label = "ZapIconIndicator", - ).value, + progress = { animatedProgress }, modifier = remember { Modifier.size(animationSize) }, strokeWidth = 2.dp, ) @@ -1227,7 +1186,13 @@ fun ObserveZapIcon( val wasZappedByLoggedInUser = remember { mutableStateOf(false) } if (!wasZappedByLoggedInUser.value) { - val zapsState by baseNote.live().zaps.observeAsState() + val zapsState by observeNoteZaps(baseNote, accountViewModel) + + zapsState?.note?.zapPayments?.forEach { + if (it.value == null) { + NWCFinderFilterAssemblerSubscription(it.key, accountViewModel) + } + } LaunchedEffect(key1 = zapsState) { if (zapsState?.note?.zapPayments?.isNotEmpty() == true || zapsState?.note?.zaps?.isNotEmpty() == true) { @@ -1249,17 +1214,22 @@ fun ObserveZapAmountText( accountViewModel: AccountViewModel, inner: @Composable (String) -> Unit, ) { - val zapsState by baseNote.live().zaps.observeAsState() + val zapsState by observeNoteZaps(baseNote, accountViewModel) if (zapsState?.note?.zapPayments?.isNotEmpty() == true) { + zapsState?.note?.zapPayments?.forEach { + if (it.value == null) { + NWCFinderFilterAssemblerSubscription(it.key, accountViewModel) + } + } + @Suppress("ProduceStateDoesNotAssignValue") val zapAmountTxt by produceState(initialValue = showAmount(baseNote.zapsAmount), key1 = zapsState) { zapsState?.note?.let { - accountViewModel.calculateZapAmount(it) { newZapAmount -> - if (value != newZapAmount) { - value = newZapAmount - } + val newZapAmount = accountViewModel.calculateZapAmount(it) + if (value != newZapAmount) { + value = newZapAmount } } } @@ -1417,7 +1387,7 @@ fun ReactionChoicePopup( val iconSizePx = with(LocalDensity.current) { -iconSize.toPx().toInt() } val reactions by accountViewModel.reactionChoicesFlow().collectAsStateWithLifecycle() - val toRemove = remember { baseNote.reactedBy(accountViewModel.userProfile()).toImmutableSet() } + val toRemove = remember { baseNote.allReactionsByAuthor(accountViewModel.userProfile()).toImmutableSet() } Popup( alignment = Alignment.BottomCenter, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt index 10db8440ca..45ff9323d5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt @@ -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 @@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.ButtonPadding import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -70,7 +71,7 @@ fun RelayCompose( ) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Text( - relay.url.trim().removePrefix("wss://"), + text = relay.url.displayUrl(), fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -108,7 +109,8 @@ private fun RelayOptions( onAddRelay: () -> Unit, onRemoveRelay: () -> Unit, ) { - val userState by accountViewModel.normalizedKind3RelaySetFlow.collectAsStateWithLifecycle() + val userState by accountViewModel.account.trustedRelays.flow + .collectAsStateWithLifecycle() if (!userState.contains(relay.url)) { AddRelayButton(onAddRelay) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt index 42d00baaf2..27bfd4599a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt @@ -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 @@ -52,7 +52,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled -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.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ShowMoreRelaysButtonBoxModifer diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt index 9b2e44df5e..f67138bb5a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt @@ -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,6 +26,7 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape @@ -37,37 +38,40 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.Nip11CachedRetriever -import com.vitorpamplona.amethyst.service.Nip11Retriever import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.LargeRelayIconModifier import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter import com.vitorpamplona.amethyst.ui.theme.Size15Modifier import com.vitorpamplona.amethyst.ui.theme.Size17dp import com.vitorpamplona.amethyst.ui.theme.StdStartPadding +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.ui.theme.allGoodColor import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.amethyst.ui.theme.redColorOnSecondSurface import com.vitorpamplona.amethyst.ui.theme.relayIconModifier import com.vitorpamplona.amethyst.ui.theme.ripple24dp -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache +import com.vitorpamplona.amethyst.ui.theme.warningColorOnSecondSurface +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Composable public fun RelayBadgesHorizontal( @@ -118,38 +122,11 @@ fun ChatRelayExpandButton(onClick: () -> Unit) { @OptIn(ExperimentalFoundationApi::class) @Composable fun RenderRelay( - relay: RelayBriefInfoCache.RelayBriefInfo, + relay: NormalizedRelayUrl, accountViewModel: AccountViewModel, nav: INav, ) { - @Suppress("ProduceStateDoesNotAssignValue") - val relayInfo by - produceState( - initialValue = Nip11CachedRetriever.getFromCache(relay.url), - ) { - if (value == null) { - accountViewModel.retrieveRelayDocument( - relay.url, - onInfo = { - value = it - }, - onError = { url, errorCode, exceptionMessage -> - }, - ) - } - } - - var openRelayDialog by remember { mutableStateOf(false) } - - if (openRelayDialog && relayInfo != null) { - RelayInformationDialog( - onClose = { openRelayDialog = false }, - relayInfo = relayInfo!!, - relayBriefInfo = relay, - accountViewModel = accountViewModel, - nav = nav, - ) - } + val relayInfo by loadRelayInfo(relay, accountViewModel) val clipboardManager = LocalClipboardManager.current val clickableModifier = @@ -162,34 +139,7 @@ fun RenderRelay( onLongClick = { clipboardManager.setText(AnnotatedString(relay.url)) }, - onClick = { - accountViewModel.retrieveRelayDocument( - relay.url, - onInfo = { - openRelayDialog = true - }, - onError = { url, errorCode, exceptionMessage -> - accountViewModel.toastManager.toast( - R.string.unable_to_download_relay_document, - when (errorCode) { - Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> - R.string.relay_information_document_error_failed_to_assemble_url - - Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> - R.string.relay_information_document_error_failed_to_reach_server - - Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> - R.string.relay_information_document_error_failed_to_parse_response - - Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> - R.string.relay_information_document_error_failed_with_http - }, - url, - exceptionMessage ?: errorCode.toString(), - ) - }, - ) - }, + onClick = { nav.nav(Route.RelayInfo(relay.url)) }, ) } @@ -198,8 +148,8 @@ fun RenderRelay( contentAlignment = Alignment.Center, ) { RenderRelayIcon( - displayUrl = relay.displayUrl, - iconUrl = relayInfo?.icon ?: relay.favIcon, + displayUrl = relayInfo.id ?: relay.url, + iconUrl = relayInfo.icon, loadProfilePicture = accountViewModel.settings.showProfilePictures.value, pingInMs = 0, loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, @@ -207,6 +157,39 @@ fun RenderRelay( } } +@Preview +@Composable +fun RenderRelayIconPreview() { + ThemeComparisonColumn { + Row { + RenderRelayIcon( + displayUrl = "wss://relay.damus.io", + iconUrl = "wss://relay.damus.io", + loadProfilePicture = true, + pingInMs = 100, + loadRobohash = true, + iconModifier = LargeRelayIconModifier, + ) + RenderRelayIcon( + displayUrl = "wss://relay.damus.io", + iconUrl = "wss://relay.damus.io", + loadProfilePicture = true, + pingInMs = 300, + loadRobohash = true, + iconModifier = LargeRelayIconModifier, + ) + RenderRelayIcon( + displayUrl = "wss://relay.damus.io", + iconUrl = "wss://relay.damus.io", + loadProfilePicture = true, + pingInMs = 500, + loadRobohash = true, + iconModifier = LargeRelayIconModifier, + ) + } + } +} + @Composable fun RenderRelayIcon( displayUrl: String, @@ -216,6 +199,9 @@ fun RenderRelayIcon( pingInMs: Long, iconModifier: Modifier = MaterialTheme.colorScheme.relayIconModifier, ) { + val green = MaterialTheme.colorScheme.allGoodColor + val yellow = MaterialTheme.colorScheme.warningColorOnSecondSurface + val red = MaterialTheme.colorScheme.redColorOnSecondSurface Box( contentAlignment = Alignment.TopEnd, ) { @@ -228,29 +214,35 @@ fun RenderRelayIcon( loadProfilePicture = loadProfilePicture, loadRobohash = loadRobohash, ) + + val textStyle = + remember(pingInMs) { + TextStyle( + color = + if (pingInMs <= 150) { + green + } else if (pingInMs <= 300) { + yellow + } else { + red + }, + ) + } + if (pingInMs > 0) { Box( modifier = Modifier - .clip(RoundedCornerShape(8.dp)) + .clip(RoundedCornerShape(6.dp)) .background( - Color.Gray, + MaterialTheme.colorScheme.secondaryContainer, ), ) { Text( - modifier = Modifier.padding(4.dp), - style = - TextStyle( - color = - if (pingInMs <= 150) { - Color.Green - } else if (pingInMs <= 300) { - Color.Yellow - } else { - Color.Red - }, - ), + modifier = Modifier.padding(3.dp), + style = textStyle, text = "$pingInMs", + fontSize = 10.sp, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt index bf095f47ad..d3392b4f40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt @@ -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 @@ -29,7 +29,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -37,9 +36,10 @@ import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer @@ -69,6 +69,7 @@ fun ReplyInformationChannel( ReplyInformationChannel( replyTo, sortedMentions, + accountViewModel = accountViewModel, onUserTagClick = { nav.nav(routeFor(it)) }, ) Spacer(modifier = StdVertSpacer) @@ -81,6 +82,7 @@ fun ReplyInformationChannel( replyTo: ImmutableList?, mentions: ImmutableList?, prefix: String = "", + accountViewModel: AccountViewModel, onUserTagClick: (User) -> Unit, ) { FlowRow { @@ -93,7 +95,7 @@ fun ReplyInformationChannel( ) mentions.forEachIndexed { idx, user -> - ReplyInfoMention(user, prefix, onUserTagClick) + ReplyInfoMention(user, prefix, accountViewModel, onUserTagClick) if (idx < mentions.size - 2) { Text( @@ -118,9 +120,10 @@ fun ReplyInformationChannel( private fun ReplyInfoMention( user: User, prefix: String, + accountViewModel: AccountViewModel, onUserTagClick: (User) -> Unit, ) { - val innerUserState by user.live().userMetadataInfo.observeAsState() + val innerUserState by observeUserInfo(user, accountViewModel) CreateClickableTextWithEmoji( clickablePart = "$prefix${innerUserState?.bestName()}", diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt index 6d9939c0f7..9f10562d34 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt index 93cbd2be3b..16060c726f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt @@ -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 @@ -51,11 +51,10 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -72,46 +71,47 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.ViewModel -import androidx.lifecycle.distinctUntilChanged -import androidx.lifecycle.map -import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.service.firstFullChar +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEventAndMap import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch class UpdateReactionTypeViewModel : ViewModel() { - var account: Account? = null + lateinit var accountViewModel: AccountViewModel + lateinit var account: Account var nextChoice by mutableStateOf(TextFieldValue("")) var reactionSet by mutableStateOf(listOf()) - fun load(myAccount: Account) { - this.account = myAccount - this.reactionSet = myAccount.settings.syncedSettings.reactions.reactionChoices.value + fun init(accountViewModel: AccountViewModel) { + this.accountViewModel = accountViewModel + this.account = accountViewModel.account + } + + fun load() { + this.reactionSet = account.settings.syncedSettings.reactions.reactionChoices.value } fun toListOfChoices(commaSeparatedAmounts: String): List = commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 } @@ -138,8 +138,7 @@ class UpdateReactionTypeViewModel : ViewModel() { } fun sendPost() { - viewModelScope.launch(Dispatchers.IO) { - account?.changeReactionTypes(reactionSet) + accountViewModel.changeReactionTypes(reactionSet) { nextChoice = TextFieldValue("") } } @@ -148,14 +147,7 @@ class UpdateReactionTypeViewModel : ViewModel() { nextChoice = TextFieldValue("") } - fun hasChanged(): Boolean = - reactionSet != - account - ?.settings - ?.syncedSettings - ?.reactions - ?.reactionChoices - ?.value + fun hasChanged(): Boolean = reactionSet != account.settings.syncedSettings.reactions.reactionChoices.value } @Composable @@ -165,7 +157,11 @@ fun UpdateReactionTypeDialog( nav: INav, ) { val postViewModel: UpdateReactionTypeViewModel = viewModel() - postViewModel.load(accountViewModel.account) + postViewModel.init(accountViewModel) + + LaunchedEffect(postViewModel, accountViewModel) { + postViewModel.load() + } UpdateReactionTypeDialog(postViewModel, onClose, accountViewModel, nav) } @@ -191,28 +187,15 @@ fun UpdateReactionTypeDialog( Scaffold( topBar = { - TopAppBar( - actions = { - SaveButton( - onPost = { - postViewModel.sendPost() - onClose() - }, - isActive = postViewModel.hasChanged(), - ) - Spacer(modifier = StdHorzSpacer) + SavingTopBar( + isActive = postViewModel::hasChanged, + onCancel = { + postViewModel.cancel() + onClose() }, - title = {}, - navigationIcon = { - Row { - Spacer(modifier = StdHorzSpacer) - CloseButton( - onPress = { - postViewModel.cancel() - onClose() - }, - ) - } + onPost = { + postViewModel.sendPost() + onClose() }, ) }, @@ -383,21 +366,13 @@ private fun EmojiSelector( onClick: ((EmojiUrlTag) -> Unit)? = null, ) { LoadAddressableNote( - accountViewModel.account.getEmojiPackSelectionAddress(), + accountViewModel.account.emoji.getEmojiPackSelectionAddress(), accountViewModel, ) { emptyNote -> emptyNote?.let { usersEmojiList -> - val collections by - usersEmojiList - .live() - .metadata - .map { (it.note.event as? EmojiPackSelectionEvent)?.emojiPackIds()?.toImmutableList() } - .distinctUntilChanged() - .observeAsState( - (usersEmojiList.event as? EmojiPackSelectionEvent) - ?.emojiPackIds() - ?.toImmutableList(), - ) + val collections by observeNoteEventAndMap(usersEmojiList, accountViewModel) { event: EmojiPackSelectionEvent -> + event.emojiPacks().toImmutableList() + } collections?.let { EmojiCollectionGallery(it, accountViewModel, nav, onClick) } } @@ -406,7 +381,7 @@ private fun EmojiSelector( @Composable fun EmojiCollectionGallery( - emojiCollections: ImmutableList, + emojiCollections: ImmutableList

, accountViewModel: AccountViewModel, nav: INav, onClick: ((EmojiUrlTag) -> Unit)? = null, @@ -439,7 +414,7 @@ private fun WatchAndRenderNote( Column( Modifier.fillMaxWidth().clickable { - scope.launch { routeFor(emojiPack, accountViewModel.userProfile())?.let { nav.nav(it) } } + scope.launch { routeFor(emojiPack, accountViewModel.account)?.let { nav.nav(it) } } }, ) { RenderEmojiPack( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index a47061d90f..907f5bf9a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -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 @@ -60,7 +60,6 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -71,166 +70,35 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton +import com.vitorpamplona.amethyst.ui.note.buttons.SaveButton +import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer -import com.vitorpamplona.amethyst.ui.screen.loggedIn.getFragmentActivity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup.getFragmentActivity import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Font14SP +import com.vitorpamplona.amethyst.ui.theme.Size24Modifier import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull -import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey -import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -@Stable -class UpdateZapAmountViewModel : ViewModel() { - var account: Account? = null - - var nextAmount by mutableStateOf(TextFieldValue("")) - var amountSet by mutableStateOf(listOf()) - var walletConnectRelay by mutableStateOf(TextFieldValue("")) - var walletConnectPubkey by mutableStateOf(TextFieldValue("")) - var walletConnectSecret by mutableStateOf(TextFieldValue("")) - var selectedZapType by mutableStateOf(LnZapEvent.ZapType.PRIVATE) - - fun copyFromClipboard(text: String) { - if (text.isBlank()) { - return - } - updateNIP47(text) - } - - fun load(myAccount: Account) { - this.account = myAccount - this.amountSet = myAccount.settings.syncedSettings.zaps.zapAmountChoices.value - this.selectedZapType = myAccount.settings.syncedSettings.zaps.defaultZapType.value - - this.walletConnectPubkey = - myAccount.settings.zapPaymentRequest - ?.pubKeyHex - ?.let { TextFieldValue(it) } ?: TextFieldValue("") - this.walletConnectRelay = - myAccount.settings.zapPaymentRequest - ?.relayUri - ?.let { TextFieldValue(it) } ?: TextFieldValue("") - this.walletConnectSecret = - myAccount.settings.zapPaymentRequest - ?.secret - ?.let { TextFieldValue(it) } ?: TextFieldValue("") - } - - fun toListOfAmounts(commaSeparatedAmounts: String): List = commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 } - - fun addAmount() { - val newValue = nextAmount.text.trim().toLongOrNull() - if (newValue != null) { - amountSet = amountSet + newValue - } - - nextAmount = TextFieldValue("") - } - - fun removeAmount(amount: Long) { - amountSet = amountSet - amount - } - - fun sendPost() { - val nip47Update = - if (walletConnectRelay.text.isNotBlank() && walletConnectPubkey.text.isNotBlank()) { - val pubkeyHex = - try { - decodePublicKey(walletConnectPubkey.text.trim()).toHexKey() - } catch (e: Exception) { - if (e is CancellationException) throw e - null - } - - val relayUrl = walletConnectRelay.text.ifBlank { null }?.let { RelayUrlFormatter.normalize(it) } - val privKeyHex = walletConnectSecret.text.ifBlank { null }?.let { decodePrivateKeyAsHexOrNull(it) } - - if (pubkeyHex != null && relayUrl != null) { - Nip47WalletConnect.Nip47URI( - pubkeyHex, - relayUrl, - privKeyHex, - ) - } else { - null - } - } else { - null - } - - viewModelScope.launch(Dispatchers.IO) { - account?.updateZapAmounts(amountSet, selectedZapType, nip47Update) - - nextAmount = TextFieldValue("") - } - } - - fun cancel() { - nextAmount = TextFieldValue("") - } - - fun hasChanged(): Boolean = - ( - selectedZapType != - account - ?.settings - ?.syncedSettings - ?.zaps - ?.defaultZapType - ?.value || - amountSet != - account - ?.settings - ?.syncedSettings - ?.zaps - ?.zapAmountChoices - ?.value || - walletConnectPubkey.text != (account?.settings?.zapPaymentRequest?.pubKeyHex ?: "") || - walletConnectRelay.text != (account?.settings?.zapPaymentRequest?.relayUri ?: "") || - walletConnectSecret.text != (account?.settings?.zapPaymentRequest?.secret ?: "") - ) - - fun updateNIP47(uri: String) { - val contact = Nip47WalletConnect.parse(uri) - if (contact != null) { - walletConnectPubkey = TextFieldValue(contact.pubKeyHex) - walletConnectRelay = TextFieldValue(contact.relayUri ?: "") - walletConnectSecret = TextFieldValue(contact.secret ?: "") - } - } -} - @Composable fun UpdateZapAmountDialog( onClose: () -> Unit, @@ -238,7 +106,11 @@ fun UpdateZapAmountDialog( accountViewModel: AccountViewModel, ) { val postViewModel: UpdateZapAmountViewModel = viewModel() - postViewModel.load(accountViewModel.account) + postViewModel.init(accountViewModel) + + LaunchedEffect(accountViewModel, postViewModel) { + postViewModel.load() + } UpdateZapAmountDialog(postViewModel, onClose, nip47uri, accountViewModel) } @@ -431,8 +303,7 @@ fun UpdateZapAmountContent( ) { TextSpinner( label = stringRes(id = R.string.zap_type_explainer), - placeholder = - zapTypes.filter { it.first == accountViewModel.defaultZapType() }.first().second, + placeholder = zapTypes.firstOrNull { it.first == accountViewModel.defaultZapType() }?.second ?: zapTypes.firstOrNull()?.second ?: "", options = zapOptions, onSelect = { postViewModel.selectedZapType = zapTypes[it].first }, modifier = Modifier.weight(1f).padding(end = 5.dp), @@ -462,9 +333,9 @@ fun UpdateZapAmountContent( }, ) { Icon( - painter = painterResource(R.drawable.alby), + painter = painterRes(R.drawable.alby, 1), contentDescription = stringRes(id = R.string.accessibility_navigate_to_alby), - modifier = Modifier.size(24.dp), + modifier = Size24Modifier, tint = Color.Unspecified, ) } @@ -477,14 +348,14 @@ fun UpdateZapAmountContent( Icon( Icons.Outlined.ContentPaste, contentDescription = stringRes(id = R.string.paste_from_clipboard), - modifier = Modifier.size(24.dp), + modifier = Size24Modifier, tint = MaterialTheme.colorScheme.primary, ) } IconButton(onClick = { qrScanning = true }) { Icon( - painter = painterResource(R.drawable.ic_qrcode), + painter = painterRes(R.drawable.ic_qrcode, 3), contentDescription = stringRes(id = R.string.accessibility_scan_qr_code), modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.primary, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt new file mode 100644 index 0000000000..7195785f32 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt @@ -0,0 +1,146 @@ +/** + * 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.ui.note + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import kotlinx.coroutines.CancellationException + +@Stable +class UpdateZapAmountViewModel : ViewModel() { + lateinit var accountViewModel: AccountViewModel + + var nextAmount by mutableStateOf(TextFieldValue("")) + var amountSet by mutableStateOf(listOf()) + var walletConnectRelay by mutableStateOf(TextFieldValue("")) + var walletConnectPubkey by mutableStateOf(TextFieldValue("")) + var walletConnectSecret by mutableStateOf(TextFieldValue("")) + var selectedZapType by mutableStateOf(LnZapEvent.ZapType.PRIVATE) + + fun copyFromClipboard(text: String) { + if (text.isBlank()) { + return + } + updateNIP47(text) + } + + fun init(accountViewModel: AccountViewModel) { + this.accountViewModel = accountViewModel + } + + fun load() { + this.amountSet = accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices.value + this.selectedZapType = accountViewModel.account.settings.syncedSettings.zaps.defaultZapType.value + + val nip47 = accountViewModel.account.settings.zapPaymentRequest.value + + this.walletConnectPubkey = nip47?.pubKeyHex?.let { TextFieldValue(it) } ?: TextFieldValue("") + this.walletConnectRelay = nip47?.relayUri?.url?.let { TextFieldValue(it) } ?: TextFieldValue("") + this.walletConnectSecret = nip47?.secret?.let { TextFieldValue(it) } ?: TextFieldValue("") + } + + fun toListOfAmounts(commaSeparatedAmounts: String): List = commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 } + + fun addAmount() { + val newValue = nextAmount.text.trim().toLongOrNull() + if (newValue != null) { + amountSet = amountSet + newValue + } + + nextAmount = TextFieldValue("") + } + + fun removeAmount(amount: Long) { + amountSet = amountSet - amount + } + + fun sendPost() { + val nip47Update = + if (walletConnectRelay.text.isNotBlank() && walletConnectPubkey.text.isNotBlank()) { + val pubkeyHex = + try { + decodePublicKey(walletConnectPubkey.text.trim()).toHexKey() + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + + val relayUrl = walletConnectRelay.text.ifBlank { null }?.let { RelayUrlNormalizer.normalizeOrNull(it) } + val privKeyHex = walletConnectSecret.text.ifBlank { null }?.let { decodePrivateKeyAsHexOrNull(it) } + + if (pubkeyHex != null && relayUrl != null) { + Nip47WalletConnect.Nip47URINorm( + pubkeyHex, + relayUrl, + privKeyHex, + ) + } else { + null + } + } else { + null + } + + accountViewModel.updateZapAmounts(amountSet, selectedZapType, nip47Update) + + nextAmount = TextFieldValue("") + } + + fun cancel() { + nextAmount = TextFieldValue("") + } + + fun hasChanged(): Boolean = + ( + selectedZapType != accountViewModel.account.settings.syncedSettings.zaps.defaultZapType.value || + amountSet != accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices.value || + walletConnectPubkey.text != ( + accountViewModel.account.settings.zapPaymentRequest.value + ?.pubKeyHex ?: "" + ) || + walletConnectRelay.text != ( + accountViewModel.account.settings.zapPaymentRequest.value + ?.relayUri ?: "" + ) || + walletConnectSecret.text != ( + accountViewModel.account.settings.zapPaymentRequest.value + ?.secret ?: "" + ) + ) + + fun updateNIP47(uri: String) { + val contact = Nip47WalletConnect.parse(uri) + walletConnectPubkey = TextFieldValue(contact.pubKeyHex) + walletConnectRelay = TextFieldValue(contact.relayUri.url) + walletConnectSecret = TextFieldValue(contact.secret ?: "") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt index 0a7016e5c6..7e862d7af5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt @@ -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 @@ -30,8 +30,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.StdPadding @@ -57,7 +57,7 @@ fun UserCompose( UsernameDisplay(baseUser, accountViewModel = accountViewModel) } - AboutDisplay(baseUser) + AboutDisplay(baseUser, accountViewModel) } Column(modifier = remember { Modifier.padding(start = 10.dp) }) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt index a3972d12d8..3cfbaec4af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt @@ -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 @@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -40,10 +39,11 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.stringRes @@ -52,10 +52,10 @@ import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey @Composable fun NoteAuthorPicture( baseNote: Note, - nav: INav, - accountViewModel: AccountViewModel, size: Dp, pictureModifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + nav: INav, ) { NoteAuthorPicture(baseNote, size, accountViewModel, pictureModifier) { nav.nav(routeFor(it)) @@ -304,7 +304,7 @@ fun BaseUserPicture( outerModifier: Modifier = Modifier.size(size), ) { Box(outerModifier, contentAlignment = Alignment.TopEnd) { - LoadUserProfilePicture(baseUser) { userProfilePicture, userName -> + LoadUserProfilePicture(baseUser, accountViewModel) { userProfilePicture, userName -> InnerUserPicture( userHex = baseUser.pubkeyHex, userPicture = userProfilePicture, @@ -326,9 +326,10 @@ fun BaseUserPicture( @Composable fun LoadUserProfilePicture( baseUser: User, + accountViewModel: AccountViewModel, innerContent: @Composable (String?, String?) -> Unit, ) { - val userProfile by baseUser.live().userMetadataInfo.observeAsState(baseUser.info) + val userProfile by observeUserInfo(baseUser, accountViewModel) innerContent(userProfile?.profilePicture(), userProfile?.bestName()) } @@ -372,7 +373,8 @@ fun WatchUserFollows( if (accountViewModel.isLoggedUser(userHex)) { onFollowChanges(true) } else { - val state by accountViewModel.account.liveKind3Follows.collectAsStateWithLifecycle() + val state by accountViewModel.account.kind3FollowList.flow + .collectAsStateWithLifecycle() onFollowChanges(state.authors.contains(userHex)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt index e003635bb5..9597070e7f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt index 6ba65b5070..e403f47965 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt @@ -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,7 +27,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -38,6 +37,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.LifecycleOwner import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.service.tts.TextToSpeechHelper import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji @@ -53,7 +54,7 @@ fun NoteUsernameDisplay( textColor: Color = Color.Unspecified, accountViewModel: AccountViewModel, ) { - WatchAuthor(baseNote) { + WatchAuthor(baseNote, accountViewModel) { UsernameDisplay(it, weight, textColor = textColor, accountViewModel = accountViewModel) } } @@ -61,15 +62,15 @@ fun NoteUsernameDisplay( @Composable fun WatchAuthor( baseNote: Note, + accountViewModel: AccountViewModel, inner: @Composable (User) -> Unit, ) { val noteAuthor = baseNote.author if (noteAuthor != null) { inner(noteAuthor) } else { - val authorState by baseNote.live().metadata.observeAsState() - - authorState?.note?.author?.let { + val authorState by observeNote(baseNote, accountViewModel) + authorState.note.author?.let { inner(it) } } @@ -86,9 +87,8 @@ fun WatchAuthorWithBlank( if (noteAuthor != null) { inner(noteAuthor) } else { - val authorState by baseNote.live().metadata.observeAsState() - - CrossfadeIfEnabled(targetState = authorState?.note?.author, modifier = modifier, label = "WatchAuthorWithBlank", accountViewModel = accountViewModel) { newAuthor -> + val authorState by observeNote(baseNote, accountViewModel) + CrossfadeIfEnabled(targetState = authorState.note.author, modifier = modifier, label = "WatchAuthorWithBlank", accountViewModel = accountViewModel) { newAuthor -> inner(newAuthor) } } @@ -102,7 +102,7 @@ fun UsernameDisplay( textColor: Color = Color.Unspecified, accountViewModel: AccountViewModel, ) { - val userMetadata by baseUser.live().userMetadataInfo.observeAsState(baseUser.info) + val userMetadata by observeUserInfo(baseUser, accountViewModel) CrossfadeIfEnabled(targetState = userMetadata, modifier = weight, label = "UsernameDisplay", accountViewModel = accountViewModel) { val name = it?.bestName() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/WatchNoteEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/WatchNoteEvent.kt index 1983a4ba32..30358376a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/WatchNoteEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/WatchNoteEvent.kt @@ -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,10 +24,10 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteHasEvent import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -74,9 +74,8 @@ fun WatchNoteEvent( onNoteEventFound() } else { // avoid observing costs if already has an event. - - val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null) - CrossfadeIfEnabled(targetState = hasEvent, label = "Event presence", accountViewModel = accountViewModel) { + val hasEvent by observeNoteHasEvent(baseNote, accountViewModel) + CrossfadeIfEnabled(targetState = hasEvent, accountViewModel = accountViewModel) { if (it) { onNoteEventFound() } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index 364865fb5c..0ebb39184b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -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,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.note import android.content.Context import android.content.Intent -import android.net.Uri import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -57,7 +56,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import androidx.core.content.ContextCompat +import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R @@ -67,8 +66,8 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage +import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer import com.vitorpamplona.amethyst.ui.stringRes @@ -85,7 +84,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException -class ZapOptionstViewModel : ViewModel() { +class ZapOptionViewModel : ViewModel() { private var account: Account? = null var customAmount by mutableStateOf(TextFieldValue("21")) @@ -112,7 +111,7 @@ fun ZapCustomDialog( baseNote: Note, ) { val context = LocalContext.current - val postViewModel: ZapOptionstViewModel = viewModel() + val postViewModel: ZapOptionViewModel = viewModel() LaunchedEffect(accountViewModel) { postViewModel.load(accountViewModel.account) } @@ -215,11 +214,7 @@ fun ZapCustomDialog( TextSpinner( label = stringRes(id = R.string.zap_type), - placeholder = - zapTypes - .filter { it.first == accountViewModel.defaultZapType() } - .first() - .second, + placeholder = zapTypes.first { it.first == accountViewModel.defaultZapType() }.second, options = zapOptions, onSelect = { selectedZapType = zapTypes[it].first }, modifier = Modifier.weight(1f).padding(end = 5.dp), @@ -233,15 +228,16 @@ fun ZapCustomDialog( OutlinedTextField( // stringRes(R.string.new_amount_in_sats label = { - if ( - selectedZapType == LnZapEvent.ZapType.PUBLIC || - selectedZapType == LnZapEvent.ZapType.ANONYMOUS - ) { - Text(text = stringRes(id = R.string.custom_zaps_add_a_message)) - } else if (selectedZapType == LnZapEvent.ZapType.PRIVATE) { - Text(text = stringRes(id = R.string.custom_zaps_add_a_message_private)) - } else if (selectedZapType == LnZapEvent.ZapType.NONZAP) { - Text(text = stringRes(id = R.string.custom_zaps_add_a_message_nonzap)) + when (selectedZapType) { + LnZapEvent.ZapType.PUBLIC, LnZapEvent.ZapType.ANONYMOUS -> { + Text(text = stringRes(id = R.string.custom_zaps_add_a_message)) + } + LnZapEvent.ZapType.PRIVATE -> { + Text(text = stringRes(id = R.string.custom_zaps_add_a_message_private)) + } + LnZapEvent.ZapType.NONZAP -> { + Text(text = stringRes(id = R.string.custom_zaps_add_a_message_nonzap)) + } } }, value = postViewModel.customMessage, @@ -296,7 +292,7 @@ fun PayViaIntentDialog( if (payingInvoices.size == 1) { val payable = payingInvoices.first() payViaIntent(payable.invoice, context, onClose) { - onError(UserBasedErrorMessage(it, payable.user)) + onError(UserBasedErrorMessage(it, payable.info.user)) } } else { Dialog( @@ -327,8 +323,8 @@ fun PayViaIntentDialog( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size10dp), ) { - if (payable.user != null) { - BaseUserPicture(payable.user, Size55dp, accountViewModel = accountViewModel) + if (payable.info.user != null) { + BaseUserPicture(payable.info.user, Size55dp, accountViewModel = accountViewModel) } else { DisplayBlankAuthor(size = Size55dp, accountViewModel = accountViewModel) } @@ -336,8 +332,8 @@ fun PayViaIntentDialog( Spacer(modifier = DoubleHorzSpacer) Column(modifier = Modifier.weight(1f)) { - if (payable.user != null) { - UsernameDisplay(payable.user, accountViewModel = accountViewModel) + if (payable.info.user != null) { + UsernameDisplay(payable.info.user, accountViewModel = accountViewModel) } else { Text( text = stringRes(id = R.string.wallet_number, index + 1), @@ -388,10 +384,10 @@ fun payViaIntent( onError: (String) -> Unit, ) { try { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$invoice")) + val intent = Intent(Intent.ACTION_VIEW, "lightning:$invoice".toUri()) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - ContextCompat.startActivity(context, intent, null) + context.startActivity(intent) onPaid() } catch (e: Exception) { if (e is CancellationException) throw e diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapFormatter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapFormatter.kt index e5024ad675..d1ca67c7a0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapFormatter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapFormatter.kt @@ -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 @@ -66,12 +66,12 @@ fun showAmount(amount: BigDecimal?): String { if (amount.abs() < BigDecimal(0.01)) return "" return when { - amount >= TenGiga -> dfGBig.get().format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) - amount >= OneGiga -> dfGSmall.get().format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) - amount >= TenMega -> dfMBig.get().format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) - amount >= OneMega -> dfMSmall.get().format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) - amount >= TenKilo -> dfK.get().format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP)) - else -> dfN.get().format(amount) + amount >= TenGiga -> dfGBig.get()!!.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) + amount >= OneGiga -> dfGSmall.get()!!.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) + amount >= TenMega -> dfMBig.get()!!.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) + amount >= OneMega -> dfMSmall.get()!!.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) + amount >= TenKilo -> dfK.get()!!.format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP)) + else -> dfN.get()!!.format(amount) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapFormatterNoDecimals.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapFormatterNoDecimals.kt index d36f532420..2b33c9b50d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapFormatterNoDecimals.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapFormatterNoDecimals.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt index e7ed80066c..6dd97fd556 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt @@ -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 @@ -29,9 +29,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -44,13 +42,16 @@ import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.observeAccountIsHiddenUser +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserAboutMe +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowing +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.FollowButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.UnfollowButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.ShowUserButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.WatchIsHiddenUser import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.ZapReqResponse import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.Size55dp @@ -65,10 +66,7 @@ fun ZapNoteCompose( accountViewModel: AccountViewModel, nav: INav, ) { - val baseNoteRequest by baseReqResponse.zapRequest - .live() - .metadata - .observeAsState() + val baseNoteRequest by observeNote(baseReqResponse.zapRequest, accountViewModel) var baseAuthor by remember { mutableStateOf(null) } @@ -117,14 +115,14 @@ private fun RenderZapNote( modifier = remember { Modifier.padding(start = 10.dp).weight(1f) }, ) { Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(baseAuthor, accountViewModel = accountViewModel) } - Row(verticalAlignment = Alignment.CenterVertically) { AboutDisplay(baseAuthor) } + Row(verticalAlignment = Alignment.CenterVertically) { AboutDisplay(baseAuthor, accountViewModel) } } Column( modifier = remember { Modifier.padding(start = 10.dp) }, verticalArrangement = Arrangement.Center, ) { - ZapAmount(zapNote) + ZapAmount(zapNote, accountViewModel) } Column(modifier = Modifier.padding(start = 10.dp)) { @@ -134,8 +132,11 @@ private fun RenderZapNote( } @Composable -private fun ZapAmount(zapEventNote: Note) { - val noteState by zapEventNote.live().metadata.observeAsState() +private fun ZapAmount( + zapEventNote: Note, + accountViewModel: AccountViewModel, +) { + val noteState by observeNote(zapEventNote, accountViewModel) var zapAmount by remember { mutableStateOf(null) } @@ -163,12 +164,11 @@ fun UserActionOptions( baseAuthor: User, accountViewModel: AccountViewModel, ) { - WatchIsHiddenUser(baseAuthor, accountViewModel) { isHidden -> - if (isHidden) { - ShowUserButton { accountViewModel.show(baseAuthor) } - } else { - ShowFollowingOrUnfollowingButton(baseAuthor, accountViewModel) - } + val isHidden by observeAccountIsHiddenUser(accountViewModel.account, baseAuthor) + if (isHidden) { + ShowUserButton { accountViewModel.show(baseAuthor) } + } else { + ShowFollowingOrUnfollowingButton(baseAuthor, accountViewModel) } } @@ -177,24 +177,9 @@ fun ShowFollowingOrUnfollowingButton( baseAuthor: User, accountViewModel: AccountViewModel, ) { - var isFollowing by remember { mutableStateOf(false) } - val accountFollowsState by accountViewModel.account - .userProfile() - .live() - .follows - .observeAsState() + var isFollowing = observeUserIsFollowing(accountViewModel.account.userProfile(), baseAuthor, accountViewModel) - LaunchedEffect(key1 = accountFollowsState) { - launch(Dispatchers.Default) { - val newShowFollowingMark = accountFollowsState?.user?.isFollowing(baseAuthor) == true - - if (newShowFollowingMark != isFollowing) { - isFollowing = newShowFollowingMark - } - } - } - - if (isFollowing) { + if (isFollowing.value) { UnfollowButton { if (!accountViewModel.isWriteable()) { accountViewModel.toastManager.toast( @@ -220,13 +205,14 @@ fun ShowFollowingOrUnfollowingButton( } @Composable -fun AboutDisplay(baseAuthor: User) { - val baseAuthorState by baseAuthor.live().metadata.observeAsState() - val userAboutMe by - remember(baseAuthorState) { derivedStateOf { baseAuthorState?.user?.info?.about ?: "" } } +fun AboutDisplay( + baseAuthor: User, + accountViewModel: AccountViewModel, +) { + val aboutMe by observeUserAboutMe(baseAuthor, accountViewModel) Text( - userAboutMe, + aboutMe, color = MaterialTheme.colorScheme.placeholderText, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt index 6f35ada2fc..044e66a493 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt @@ -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,8 +34,8 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.ZapUserSetCard import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer @@ -106,7 +106,7 @@ fun ZapUserSetCompose( Column(modifier = remember { Modifier.padding(start = 10.dp).weight(1f) }) { Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(zapSetCard.user, accountViewModel = accountViewModel) } - AboutDisplay(zapSetCard.user) + AboutDisplay(zapSetCard.user, accountViewModel) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/buttons/CloseButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/buttons/CloseButton.kt new file mode 100644 index 0000000000..9a9a4bb0b0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/buttons/CloseButton.kt @@ -0,0 +1,42 @@ +/** + * 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.ui.note.buttons + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.OutlinedButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.ui.note.CloseIcon +import com.vitorpamplona.amethyst.ui.theme.Size5dp + +@Composable +fun CloseButton( + onPress: () -> Unit, + modifier: Modifier = Modifier, +) { + OutlinedButton( + onClick = onPress, + modifier = modifier, + contentPadding = PaddingValues(horizontal = Size5dp), + ) { + CloseIcon() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/buttons/PostButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/buttons/PostButton.kt new file mode 100644 index 0000000000..b558b59e38 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/buttons/PostButton.kt @@ -0,0 +1,43 @@ +/** + * 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.ui.note.buttons + +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun PostButton( + onPost: () -> Unit = {}, + isActive: Boolean, + modifier: Modifier = Modifier, +) { + Button( + modifier = modifier, + enabled = isActive, + onClick = onPost, + ) { + Text(text = stringRes(R.string.post)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/buttons/SaveButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/buttons/SaveButton.kt new file mode 100644 index 0000000000..88ed9e3816 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/buttons/SaveButton.kt @@ -0,0 +1,43 @@ +/** + * 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.ui.note.buttons + +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun SaveButton( + onPost: () -> Unit = {}, + isActive: Boolean, + modifier: Modifier = Modifier, +) { + Button( + enabled = isActive, + modifier = modifier, + onClick = onPost, + ) { + Text(text = stringRes(R.string.save)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/contentWarning/ContentSensitivityExplainer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/contentWarning/ContentSensitivityExplainer.kt index cbd20088e4..2de0095f24 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/contentWarning/ContentSensitivityExplainer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/contentWarning/ContentSensitivityExplainer.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/contentWarning/MarkAsSensitiveButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/contentWarning/MarkAsSensitiveButton.kt index 366b9e5ca7..de142b8efe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/contentWarning/MarkAsSensitiveButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/contentWarning/MarkAsSensitiveButton.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/contentWarning/SettingSwitchItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/contentWarning/SettingSwitchItem.kt index 6dac56a75b..c0b6232d72 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/contentWarning/SettingSwitchItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/contentWarning/SettingSwitchItem.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/draftTags/DraftTagState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/draftTags/DraftTagState.kt index 2f01811f97..363f34e362 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/draftTags/DraftTagState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/draftTags/DraftTagState.kt @@ -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 @@ -44,6 +44,7 @@ class DraftTagState { fun rotate() { set(newTag()) + _versions.update { 0 } } fun set(existingTag: String) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/EmojiSuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/EmojiSuggestionState.kt index 5af179240c..82b6f5e235 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/EmojiSuggestionState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/EmojiSuggestionState.kt @@ -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,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions -import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -32,9 +32,9 @@ class EmojiSuggestionState( val accountViewModel: AccountViewModel, ) { val search: MutableStateFlow = MutableStateFlow("") - val results: Flow> = + val results: Flow> = accountViewModel.account - .myEmojis + .emoji.myEmojis .combine(search) { list, search -> if (search.length == 1) { list diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/ShowEmojiSuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/ShowEmojiSuggestionList.kt index b4275c89b7..fd81456ac3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/ShowEmojiSuggestionList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/ShowEmojiSuggestionList.kt @@ -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 @@ -44,7 +44,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.UrlImageView import com.vitorpamplona.amethyst.ui.stringRes @@ -55,8 +55,8 @@ import com.vitorpamplona.amethyst.ui.theme.Size40Modifier @Composable fun ShowEmojiSuggestionList( emojiSuggestions: EmojiSuggestionState, - onSelect: (Account.EmojiMedia) -> Unit, - onFullSize: (Account.EmojiMedia) -> Unit, + onSelect: (EmojiPackState.EmojiMedia) -> Unit, + onFullSize: (EmojiPackState.EmojiMedia) -> Unit, accountViewModel: AccountViewModel, modifier: Modifier = Modifier.heightIn(0.dp, 200.dp), ) { @@ -80,7 +80,7 @@ fun ShowEmojiSuggestionList( horizontalArrangement = spacedBy(Size10dp), ) { Box(Size40Modifier) { - UrlImageView(it.url, accountViewModel) + UrlImageView(it.link, accountViewModel) } Text(it.code, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) Box(Size40Modifier, contentAlignment = Alignment.Center) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/WatchAndLoadMyEmojiList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/WatchAndLoadMyEmojiList.kt index 94ec784e30..35209d6e8c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/WatchAndLoadMyEmojiList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/WatchAndLoadMyEmojiList.kt @@ -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,9 +22,8 @@ package com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.lifecycle.distinctUntilChanged -import androidx.lifecycle.map +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEventAndMap import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses @@ -38,23 +37,15 @@ fun WatchAndLoadMyEmojiList(accountViewModel: AccountViewModel) { accountViewModel, ) { emptyNote -> emptyNote?.let { usersEmojiList -> - val collections by usersEmojiList - .live() - .metadata - .map { - (it.note.event as? EmojiPackSelectionEvent) - ?.taggedAddresses() - ?.toImmutableList() - }.distinctUntilChanged() - .observeAsState( - (usersEmojiList.event as? EmojiPackSelectionEvent) - ?.taggedAddresses() - ?.toImmutableList(), - ) + val collections by observeNoteEventAndMap(usersEmojiList, accountViewModel) { event: EmojiPackSelectionEvent -> + event.taggedAddresses().toImmutableList() + } - collections?.forEach { - LoadAddressableNote(it, accountViewModel) { - it?.live()?.metadata?.observeAsState() + collections?.forEach { address -> + LoadAddressableNote(address, accountViewModel) { note -> + if (note != null) { + EventFinderFilterAssemblerSubscription(note, accountViewModel) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/AddLnInvoiceButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/AddLnInvoiceButton.kt index e449bef1e9..636170cc06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/AddLnInvoiceButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/AddLnInvoiceButton.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePreview.kt index 416627de66..7ade7a83f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePreview.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoiceRequest.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoiceRequest.kt index 15da2a8826..59baf599eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoiceRequest.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoiceRequest.kt @@ -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 @@ -35,6 +35,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -51,6 +52,7 @@ import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.commons.hashtags.Lightning +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -62,7 +64,7 @@ import com.vitorpamplona.amethyst.ui.theme.subtleBorder @Composable fun InvoiceRequestCard( lud16: String, - toUserPubKeyHex: String, + user: User, accountViewModel: AccountViewModel, titleText: String? = null, buttonText: String? = null, @@ -80,7 +82,7 @@ fun InvoiceRequestCard( ) { InvoiceRequest( lud16, - toUserPubKeyHex, + user, accountViewModel, titleText, buttonText, @@ -93,11 +95,11 @@ fun InvoiceRequestCard( @Composable fun InvoiceRequest( lud16: String, - toUserPubKeyHex: String, + user: User, accountViewModel: AccountViewModel, titleText: String? = null, buttonText: String? = null, - onSuccess: (String) -> Unit, + onNewInvoice: (String) -> Unit, onError: (String, String) -> Unit, ) { val context = LocalContext.current @@ -125,7 +127,7 @@ fun InvoiceRequest( HorizontalDivider(thickness = DividerThickness) var message by remember { mutableStateOf("") } - var amount by remember { mutableStateOf(1000L) } + var amount by remember { mutableLongStateOf(1000L) } OutlinedTextField( label = { Text(text = stringRes(R.string.note_to_receiver)) }, @@ -175,11 +177,11 @@ fun InvoiceRequest( modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), onClick = { accountViewModel.sendSats( - lnaddress = lud16, + lnAddress = lud16, + user = user, milliSats = amount * 1000, message = message, - toUserPubKeyHex = toUserPubKeyHex, - onSuccess = onSuccess, + onNewInvoice = onNewInvoice, onError = onError, onProgress = {}, context = context, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/NewPostInvoiceRequest.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/NewPostInvoiceRequest.kt index 30074a7750..a2e04533e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/NewPostInvoiceRequest.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/NewPostInvoiceRequest.kt @@ -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 @@ -30,15 +30,21 @@ fun NewPostInvoiceRequest( onSuccess: (String) -> Unit, accountViewModel: AccountViewModel, ) { - accountViewModel.account.userProfile().info?.lnAddress()?.let { lud16 -> + val lnAddress = + accountViewModel.account + .userProfile() + .info + ?.lnAddress() + + if (lnAddress != null) { InvoiceRequest( - lud16, - accountViewModel.account.userProfile().pubkeyHex, - accountViewModel, - stringRes(id = R.string.lightning_invoice), - stringRes(id = R.string.lightning_create_and_add_invoice), - onSuccess = onSuccess, - onError = { title, message -> accountViewModel.toastManager.toast(title, message) }, + lud16 = lnAddress, + user = accountViewModel.account.userProfile(), + accountViewModel = accountViewModel, + titleText = stringRes(id = R.string.lightning_invoice), + buttonText = stringRes(id = R.string.lightning_create_and_add_invoice), + onNewInvoice = onSuccess, + onError = accountViewModel.toastManager::toast, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/AddGeoHashButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/AddGeoHashButton.kt index 923a767c79..a4ba3cb098 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/AddGeoHashButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/AddGeoHashButton.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/DisplayLocationObserver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/DisplayLocationObserver.kt index d6fc2f8a23..19ebef5bce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/DisplayLocationObserver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/DisplayLocationObserver.kt @@ -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,7 +25,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/ILocationGrabber.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/ILocationGrabber.kt index 6a09ab5f69..65e4b51825 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/ILocationGrabber.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/ILocationGrabber.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LoadCityName.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LoadCityName.kt index d60af42078..62832db45b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LoadCityName.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LoadCityName.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationAsHash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationAsHash.kt index 3fa433518e..3efd11f956 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationAsHash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationAsHash.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/IMessageField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/IMessageField.kt index f6fa1901b7..70e564d644 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/IMessageField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/IMessageField.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt index d28f44b6f8..3727ab0bc3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt @@ -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 @@ -29,7 +29,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/Notifying.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/Notifying.kt new file mode 100644 index 0000000000..519ebea287 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/Notifying.kt @@ -0,0 +1,98 @@ +/** + * 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.ui.note.creators.notify + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUser +import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ButtonBorder +import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import kotlinx.collections.immutable.ImmutableList + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun Notifying( + baseMentions: ImmutableList?, + accountViewModel: AccountViewModel, + onClick: (User) -> Unit, +) { + val mentions = baseMentions?.toSet() + + FlowRow(horizontalArrangement = Arrangement.spacedBy(5.dp)) { + if (!mentions.isNullOrEmpty()) { + Text( + stringRes(R.string.reply_notify), + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.align(CenterVertically), + ) + + mentions.forEachIndexed { idx, user -> + Button( + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.mediumImportanceLink, + ), + onClick = { onClick(user) }, + ) { + DisplayUserNameWithDeleteMark(user, accountViewModel) + } + } + } + } +} + +@Composable +private fun DisplayUserNameWithDeleteMark( + user: User, + accountViewModel: AccountViewModel, +) { + val innerUserState by observeUser(user, accountViewModel) + innerUserState?.user?.let { myUser -> + CreateTextWithEmoji( + text = remember(innerUserState) { "✖ ${myUser.toBestDisplayName()}" }, + tags = myUser.info?.tags, + color = Color.White, + textAlign = TextAlign.Center, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/DisplayPreviews.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/DisplayPreviews.kt new file mode 100644 index 0000000000..87ddf7a021 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/DisplayPreviews.kt @@ -0,0 +1,64 @@ +/** + * 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.ui.note.creators.previews + +import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.FillWidthQuoteBorderModifier +import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding +import com.vitorpamplona.amethyst.ui.theme.Height100Modifier +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.SquaredQuoteBorderModifier + +@Composable +fun DisplayPreviews( + state: PreviewState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val urlPreviews by state.results.collectAsStateWithLifecycle(emptyList()) + + if (urlPreviews.isNotEmpty()) { + Row(HalfHorzPadding) { + if (urlPreviews.size > 1) { + LazyRow(Height100Modifier, horizontalArrangement = spacedBy(Size5dp)) { + items(urlPreviews) { + Box(SquaredQuoteBorderModifier) { + PreviewUrl(it, accountViewModel, nav) + } + } + } + } else { + Box(FillWidthQuoteBorderModifier) { + PreviewUrlFillWidth(urlPreviews[0], accountViewModel, nav) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewState.kt index 95ba457499..096d8c4be6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewState.kt @@ -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,9 @@ package com.vitorpamplona.amethyst.ui.note.creators.previews import androidx.compose.ui.text.input.TextFieldValue -import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.service.CachedUrlParser import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged @@ -32,17 +33,13 @@ import kotlinx.coroutines.flow.map class PreviewState { var source = MutableStateFlow(TextFieldValue("")) + @OptIn(FlowPreview::class) val results = source - .debounce(200) + .debounce(500) + .map { CachedUrlParser.parseValidUrls(it.text) } .distinctUntilChanged() - .map { - if (it.text.isNotEmpty()) { - RichTextParser().parseValidUrls(it.text).toList() - } else { - emptyList() - } - }.flowOn(Dispatchers.Default) + .flowOn(Dispatchers.Default) fun reset() { source.tryEmit(TextFieldValue("")) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt index 305474f9c8..7be2f5204f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt @@ -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,6 +25,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -45,8 +46,11 @@ import com.vitorpamplona.amethyst.model.UrlCachedPreviewer import com.vitorpamplona.amethyst.service.playback.composable.VideoView import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.ClickableUrl +import com.vitorpamplona.amethyst.ui.components.DisplayUrlWithLoadingSymbol +import com.vitorpamplona.amethyst.ui.components.UrlPreviewCard import com.vitorpamplona.amethyst.ui.components.UrlPreviewState -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.components.WaitAndDisplay +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -93,6 +97,49 @@ fun PreviewUrl( } } +@Composable +fun PreviewUrlFillWidth( + myUrlPreview: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (RichTextParser.isValidURL(myUrlPreview)) { + if (RichTextParser.isImageUrl(myUrlPreview)) { + AsyncImage( + model = myUrlPreview, + contentDescription = myUrlPreview, + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxHeight().aspectRatio(1f), + ) + } else if (RichTextParser.isVideoUrl(myUrlPreview)) { + VideoView( + myUrlPreview, + mimeType = null, + roundedCorner = false, + gallery = false, + contentScale = ContentScale.FillWidth, + accountViewModel = accountViewModel, + ) + } else { + MyLoadUrlPreviewDirectFillWidth(myUrlPreview, myUrlPreview, accountViewModel) + } + } else if (RichTextParser.startsWithNIP19Scheme(myUrlPreview)) { + val bgColor = MaterialTheme.colorScheme.background + val backgroundColor = remember { mutableStateOf(bgColor) } + + BechLinkPreview( + word = myUrlPreview, + canPreview = true, + quotesLeft = 1, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } else if (RichTextParser.isUrlWithoutScheme(myUrlPreview)) { + MyLoadUrlPreviewDirectFillWidth("https://$myUrlPreview", myUrlPreview, accountViewModel) + } +} + @Composable private fun BechLinkPreview( word: String, @@ -154,17 +201,17 @@ private fun MyLoadUrlPreviewDirect( ) { state -> when (state) { is UrlPreviewState.Loaded -> { - if (state.previewInfo.mimeType.type == "image") { + if (state.previewInfo.mimeType.startsWith("image")) { AsyncImage( model = state.previewInfo.url, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxHeight().aspectRatio(1f), ) - } else if (state.previewInfo.mimeType.type == "video") { + } else if (state.previewInfo.mimeType.startsWith("video")) { VideoView( state.previewInfo.url, - mimeType = state.previewInfo.mimeType.toString(), + mimeType = state.previewInfo.mimeType, roundedCorner = false, gallery = false, contentScale = ContentScale.Crop, @@ -196,3 +243,59 @@ private fun MyLoadUrlPreviewDirect( } } } + +@Composable +private fun MyLoadUrlPreviewDirectFillWidth( + url: String, + urlText: String, + accountViewModel: AccountViewModel, +) { + @Suppress("ProduceStateDoesNotAssignValue") + val urlPreviewState by + produceState( + initialValue = UrlCachedPreviewer.cache.get(url) ?: UrlPreviewState.Loading, + key1 = url, + ) { + if (value == UrlPreviewState.Loading) { + accountViewModel.urlPreview(url) { value = it } + } + } + + CrossfadeIfEnabled( + targetState = urlPreviewState, + label = "UrlPreview", + accountViewModel = accountViewModel, + ) { state -> + when (state) { + is UrlPreviewState.Loaded -> { + if (state.previewInfo.mimeType.startsWith("image")) { + AsyncImage( + model = state.previewInfo.url, + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth(), + ) + } else if (state.previewInfo.mimeType.startsWith("video")) { + VideoView( + state.previewInfo.url, + mimeType = state.previewInfo.mimeType, + roundedCorner = false, + gallery = false, + contentScale = ContentScale.FillWidth, + accountViewModel = accountViewModel, + ) + } else { + UrlPreviewCard(url, previewInfo = state.previewInfo) + } + } + is UrlPreviewState.Loading -> { + WaitAndDisplay { + DisplayUrlWithLoadingSymbol(url) + } + } + else -> { + ClickableUrl(urlText, url) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/secretEmoji/AddSecretEmojiButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/secretEmoji/AddSecretEmojiButton.kt index f40f316de6..2c93c2db92 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/secretEmoji/AddSecretEmojiButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/secretEmoji/AddSecretEmojiButton.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/secretEmoji/SecretEmojiRequest.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/secretEmoji/SecretEmojiRequest.kt index 0f3c40128e..7eadae5963 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/secretEmoji/SecretEmojiRequest.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/secretEmoji/SecretEmojiRequest.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt index fa382cebcb..a119bc8a94 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt @@ -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 @@ -82,17 +82,35 @@ fun ImageVideoDescription( onDelete: (SelectedMediaProcessing) -> Unit, onCancel: () -> Unit, accountViewModel: AccountViewModel, +) { + ImageVideoDescription(uris, defaultServer, true, onAdd, onDelete, onCancel, accountViewModel) +} + +@Composable +fun ImageVideoDescription( + uris: MultiOrchestrator, + defaultServer: ServerName, + includeNIP95: Boolean, + onAdd: (String, ServerName, Boolean, Int) -> Unit, + onDelete: (SelectedMediaProcessing) -> Unit, + onCancel: () -> Unit, + accountViewModel: AccountViewModel, ) { val nip95description = stringRes(id = R.string.upload_server_relays_nip95) - val fileServers by accountViewModel.account.liveServerList.collectAsState() + val fileServers by accountViewModel.account.serverLists.liveServerList + .collectAsState() val fileServerOptions = - remember(fileServers) { + remember(fileServers, includeNIP95) { fileServers - .map { + .mapNotNull { if (it.type == ServerType.NIP95) { - TitleExplainer(it.name, nip95description) + if (includeNIP95) { + TitleExplainer(it.name, nip95description) + } else { + null + } } else { TitleExplainer(it.name, it.baseUrl) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt index ae4e67e719..373a6f1d3d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt @@ -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 @@ -28,20 +28,28 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription import com.vitorpamplona.amethyst.ui.note.AboutDisplay import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.AnimateOnNewSearch import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch @Composable fun ShowUserSuggestionList( @@ -50,14 +58,57 @@ fun ShowUserSuggestionList( accountViewModel: AccountViewModel, modifier: Modifier = Modifier.heightIn(0.dp, 200.dp), ) { - val userSuggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList()) + UserSearchDataSourceSubscription(userSuggestions, accountViewModel) - if (userSuggestions.isNotEmpty()) { + val listState = rememberLazyListState() + + AnimateOnNewSearch(userSuggestions, listState) + + LaunchedEffect(Unit) { + launch(Dispatchers.Default) { + LocalCache.live.newEventBundles.collect { + userSuggestions.invalidateData() + } + } + launch(Dispatchers.Default) { + LocalCache.live.deletedEventBundles.collect { + userSuggestions.invalidateData() + } + } + } + + WatchResponses(userSuggestions, listState, onSelect, accountViewModel, modifier) +} + +@Composable +fun AnimateOnNewSearch( + userSuggestions: UserSuggestionState, + listState: LazyListState, +) { + val searchTerm by userSuggestions.searchTerm.collectAsStateWithLifecycle("") + + LaunchedEffect(searchTerm) { + listState.animateScrollToItem(0) + } +} + +@Composable +fun WatchResponses( + userSuggestions: UserSuggestionState, + listState: LazyListState, + onSelect: (User) -> Unit, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier.heightIn(0.dp, 200.dp), +) { + val suggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList()) + + if (suggestions.isNotEmpty()) { LazyColumn( contentPadding = PaddingValues(top = 10.dp), modifier = modifier, + state = listState, ) { - itemsIndexed(userSuggestions, key = { _, item -> item.pubkeyHex }) { _, item -> + itemsIndexed(suggestions, key = { _, item -> item.pubkeyHex }) { _, item -> UserLine(item, accountViewModel) { onSelect(item) } HorizontalDivider( thickness = DividerThickness, @@ -94,7 +145,7 @@ fun UserLine( ) } - AboutDisplay(baseUser) + AboutDisplay(baseUser, accountViewModel) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt index 6dee26cd3b..453ab6a778 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt @@ -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,43 +22,74 @@ package com.vitorpamplona.amethyst.ui.note.creators.userSuggestions import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue +import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource +import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update class UserSuggestionState( val accountViewModel: AccountViewModel, ) { - var search = MutableStateFlow("") - var results = - search - .debounce(500) + val invalidations = MutableStateFlow(0) + val currentWord = MutableStateFlow("") + val searchDataSourceState = SearchQueryState(MutableStateFlow(""), accountViewModel.account) + + @OptIn(FlowPreview::class) + val searchTerm = + currentWord + .debounce(300) .distinctUntilChanged() - .map { word -> - if (word.startsWith("@") && word.length > 2) { - val prefix = word.removePrefix("@") - NostrSearchEventOrUserDataSource.search(prefix) + .map(::userSearchTermOrNull) + .onEach(::updateDataSource) + + @OptIn(FlowPreview::class) + val results = + combine(searchTerm, invalidations.debounce(100)) { prefix, version -> + if (prefix != null) { + logTime("UserSuggestionState Search $prefix version $version") { accountViewModel.findUsersStartingWithSync(prefix) - } else { - NostrSearchEventOrUserDataSource.clear() - search.tryEmit("") - emptyList() } - }.flowOn(Dispatchers.IO) + } else { + emptyList() + } + }.flowOn(Dispatchers.Default) fun reset() { - NostrSearchEventOrUserDataSource.clear() - search.tryEmit("") + currentWord.tryEmit("") } fun processCurrentWord(word: String) { - search.tryEmit(word) + currentWord.tryEmit(word) + } + + fun invalidateData() { + // force new query + invalidations.update { it + 1 } + } + + fun userSearchTermOrNull(currentWord: String): String? = + if (currentWord.startsWith("@") && currentWord.length > 2) { + currentWord.removePrefix("@") + } else { + null + } + + fun updateDataSource(searchTerm: String?) { + if (searchTerm != null) { + searchDataSourceState.searchQuery.tryEmit(searchTerm) + } else { + searchDataSourceState.searchQuery.tryEmit("") + } } fun replaceCurrentWord( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/PollField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/PollField.kt index 9d34a7faf4..1928d52943 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/PollField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/PollField.kt @@ -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 @@ -29,16 +29,16 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.ui.actions.NewPollOption import com.vitorpamplona.amethyst.ui.actions.NewPollVoteValueRange -import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel +import com.vitorpamplona.amethyst.ui.painterRes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel import com.vitorpamplona.amethyst.ui.theme.Size18Modifier import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable -fun PollField(postViewModel: NewPostViewModel) { +fun PollField(postViewModel: ShortNotePostViewModel) { val optionsList = postViewModel.pollOptions Column( modifier = Modifier.fillMaxWidth(), @@ -65,7 +65,7 @@ fun PollField(postViewModel: NewPostViewModel) { ), ) { Image( - painterResource(id = android.R.drawable.ic_input_add), + painter = painterRes(resourceId = android.R.drawable.ic_input_add, 1), contentDescription = "Add poll option button", modifier = Size18Modifier, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapraiser/AddZapraiserButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapraiser/AddZapraiserButton.kt index 13f7376e05..c43653094e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapraiser/AddZapraiserButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapraiser/AddZapraiserButton.kt @@ -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,7 +27,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ShowChart import androidx.compose.material.icons.filled.Bolt -import androidx.compose.material.icons.filled.ShowChart import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapraiser/IZapRaiser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapraiser/IZapRaiser.kt index 317b086f68..121f7dc6fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapraiser/IZapRaiser.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapraiser/IZapRaiser.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapraiser/ZapRaiserRequest.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapraiser/ZapRaiserRequest.kt index de3b71b406..4e07bdf67c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapraiser/ZapRaiserRequest.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapraiser/ZapRaiserRequest.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/DisplayZapSplits.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/DisplayZapSplits.kt index 5afd85d801..52c1b032e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/DisplayZapSplits.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/DisplayZapSplits.kt @@ -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 @@ -28,7 +28,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapTo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapTo.kt index 1aeb55db99..751dae7cef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapTo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapTo.kt @@ -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 @@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.placeholderText +import java.util.Locale import kotlin.math.round @Composable @@ -102,7 +103,7 @@ fun ForwardZapTo( Column(modifier = Modifier.weight(1f)) { UsernameDisplay(splitItem.key, accountViewModel = accountViewModel) Text( - text = String.format("%.0f%%", splitItem.percentage * 100), + text = String.format(Locale.getDefault(), "%.0f%%", splitItem.percentage * 100), maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.Bold, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapToButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapToButton.kt index 497312a814..2a83d7fe4d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapToButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapToButton.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/IZapField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/IZapField.kt index ced6fad983..9a85f61784 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/IZapField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/IZapField.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/SplitBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/SplitBuilder.kt index 1fd52fc0e8..d9c5d65960 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/SplitBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/SplitBuilder.kt @@ -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,14 +21,10 @@ package com.vitorpamplona.amethyst.ui.note.creators.zapsplits import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import kotlin.collections.all -import kotlin.collections.getOrNull -import kotlin.collections.lastIndex -import kotlin.compareTo import kotlin.math.abs -import kotlin.text.toDouble class SplitBuilder { var items: List> by mutableStateOf(emptyList()) @@ -130,5 +126,5 @@ class SplitItem( val key: T, ) { // 0 to 1 - var percentage by mutableStateOf(0f) + var percentage by mutableFloatStateOf(0f) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/SplitConversor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/SplitConversor.kt index 7c65c1f7f0..b5e8f99679 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/SplitConversor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/SplitConversor.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt index d81c829d5a..45684c691d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt @@ -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 @@ -29,7 +29,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -39,8 +38,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel @@ -102,12 +102,11 @@ fun ObserveRelayListForDMs( inner: @Composable (relayListEvent: ChatMessageRelayListEvent?) -> Unit, ) { LoadAddressableNote( - ChatMessageRelayListEvent.createAddressTag(pubkey), + ChatMessageRelayListEvent.createAddress(pubkey), accountViewModel, ) { relayList -> if (relayList != null) { - val relayListNoteState by relayList.live().metadata.observeAsState() - val relayListEvent = relayListNoteState?.note?.event as? ChatMessageRelayListEvent + val relayListEvent by observeNoteEvent(relayList, accountViewModel) inner(relayListEvent) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddRemoveButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddRemoveButtons.kt index cd1d15aae4..46d4857b70 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddRemoveButtons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddRemoveButtons.kt @@ -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,6 @@ package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -56,20 +55,16 @@ fun AddButtonPreview() { @Composable fun AddButton( + modifier: Modifier = Modifier, text: Int = R.string.add, isActive: Boolean = true, - modifier: Modifier = Modifier.padding(start = 3.dp), onClick: () -> Unit, ) { OutlinedButton( modifier = modifier, - onClick = { - if (isActive) { - onClick() - } - }, - shape = ButtonBorder, enabled = isActive, + onClick = onClick, + shape = ButtonBorder, contentPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp), ) { Text(text = stringRes(text), textAlign = TextAlign.Center) @@ -78,20 +73,18 @@ fun AddButton( @Composable fun RemoveButton( + modifier: Modifier = Modifier, + text: Int = R.string.remove, isActive: Boolean = true, onClick: () -> Unit, ) { OutlinedButton( - modifier = Modifier.padding(start = 3.dp), - onClick = { - if (isActive) { - onClick() - } - }, + modifier = modifier, + onClick = onClick, shape = ButtonBorder, enabled = isActive, contentPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp), ) { - Text(text = stringRes(R.string.remove)) + Text(text = stringRes(text)) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt index 47bd833952..5cdcf052cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt index 90b4d65a05..5d0c30fdc2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt @@ -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,24 +22,24 @@ package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserBanner +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.WatchAuthor +import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.SimpleHeaderImage +import com.vitorpamplona.amethyst.ui.theme.Size16dp import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.authorNotePictureForImageHeader @@ -47,10 +47,28 @@ import com.vitorpamplona.amethyst.ui.theme.authorNotePictureForImageHeader fun DefaultImageHeader( note: Note, accountViewModel: AccountViewModel, + modifier: Modifier = SimpleHeaderImage, ) { - WatchAuthor(baseNote = note) { + WatchAuthor(baseNote = note, accountViewModel) { Box { - BannerImage(it) + BannerImage(it, modifier, accountViewModel) + + Box(authorNotePictureForImageHeader.align(Alignment.BottomStart)) { + BaseUserPicture(it, Size55dp, accountViewModel, Modifier) + } + } + } +} + +@Composable +fun DefaultImageHeaderBackground( + note: Note, + accountViewModel: AccountViewModel, + modifier: Modifier = SimpleHeaderImage, +) { + WatchAuthor(baseNote = note, accountViewModel) { + Box { + BannerImage(it, modifier.blur(Size16dp), accountViewModel) Box(authorNotePictureForImageHeader.align(Alignment.BottomStart)) { BaseUserPicture(it, Size55dp, accountViewModel, Modifier) @@ -62,27 +80,55 @@ fun DefaultImageHeader( @Composable fun BannerImage( author: User, - imageModifier: Modifier = Modifier.fillMaxWidth().heightIn(max = 200.dp), + modifier: Modifier = Modifier, + accountViewModel: AccountViewModel, ) { - val currentInfo by author.live().userMetadataInfo.observeAsState() - currentInfo?.banner?.let { - AsyncImage( - model = it, + val banner by observeUserBanner(author, accountViewModel) + + BannerImage(banner, modifier, accountViewModel) +} + +@Composable +fun BannerImage( + banner: String?, + modifier: Modifier = Modifier, + accountViewModel: AccountViewModel, +) { + if (!banner.isNullOrBlank()) { + MyAsyncImage( + imageUrl = banner, contentDescription = stringRes( R.string.preview_card_image_for, - it, + banner, ), contentScale = ContentScale.Crop, - modifier = imageModifier, - placeholder = painterResource(R.drawable.profile_banner), + mainImageModifier = Modifier, + loadedImageModifier = modifier, + accountViewModel = accountViewModel, + onLoadingBackground = { + Image( + painter = painterRes(R.drawable.profile_banner, 4), + contentDescription = stringRes(R.string.profile_banner), + contentScale = ContentScale.Crop, + modifier = modifier, + ) + }, + onError = { + Image( + painter = painterRes(R.drawable.profile_banner, 4), + contentDescription = stringRes(R.string.profile_banner), + contentScale = ContentScale.Crop, + modifier = modifier, + ) + }, ) - } ?: run { + } else { Image( - painter = painterResource(R.drawable.profile_banner), + painter = painterRes(R.drawable.profile_banner, 5), contentDescription = stringRes(R.string.profile_banner), contentScale = ContentScale.Crop, - modifier = imageModifier, + modifier = modifier, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt index a686b891c6..877b417d41 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt @@ -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,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.note.elements -import android.R.attr.maxLines import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.material3.LocalTextStyle @@ -31,8 +30,8 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.buildLinkString -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag @@ -60,7 +59,7 @@ private fun DisplayCommunity( val displayTag = remember(note) { - buildLinkString(getCommunityShortName(communityTag)) { nav.nav(Route.Community(communityTag.toTag())) } + buildLinkString(getCommunityShortName(communityTag)) { nav.nav(Route.Community(communityTag.kind, communityTag.pubKeyHex, communityTag.dTag)) } } Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt index 3829d9109d..f12ce1d194 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt index 3ed6aba7ae..13a3f70c0d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt @@ -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,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.note.elements -import android.R.attr.maxLines import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -38,10 +37,12 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.buildLinkString -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.tags.hashtags.firstIsTaggedHashes +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId @Composable fun DisplayFollowingHashtagsInPost( @@ -49,11 +50,19 @@ fun DisplayFollowingHashtagsInPost( accountViewModel: AccountViewModel, nav: INav, ) { - val userFollowState by accountViewModel.account.liveKind3Follows.collectAsStateWithLifecycle() + val userFollowState by accountViewModel.account.allFollows.flow + .collectAsStateWithLifecycle() var firstTag by remember(baseNote) { mutableStateOf(null) } LaunchedEffect(key1 = userFollowState) { - val newFirstTag = baseNote.event?.firstIsTaggedHashes(userFollowState.hashtags) + val noteEvent = baseNote.event + + val newFirstTag = + if (noteEvent is CommentEvent) { + noteEvent.firstTaggedScopeIn(userFollowState.hashtagScopes)?.let { HashtagId.parse(it) } ?: noteEvent.firstIsTaggedHashes(userFollowState.hashtags) + } else { + noteEvent?.firstIsTaggedHashes(userFollowState.hashtags) + } if (firstTag != newFirstTag) { firstTag = newFirstTag @@ -62,7 +71,7 @@ fun DisplayFollowingHashtagsInPost( firstTag?.let { Column(verticalArrangement = Arrangement.Center) { - Row(verticalAlignment = Alignment.CenterVertically) { DisplayTagList(it, nav) } + Row(verticalAlignment = Alignment.CenterVertically) { DisplayTagList(it, accountViewModel, nav) } } } } @@ -70,6 +79,7 @@ fun DisplayFollowingHashtagsInPost( @Composable private fun DisplayTagList( firstTag: String, + accountViewModel: AccountViewModel, nav: INav, ) { Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt index 8e23cc3954..33a443a547 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt @@ -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 @@ -28,14 +28,16 @@ import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withLink -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Font14SP @Composable fun DisplayLocation( geohashStr: String, + accountViewModel: AccountViewModel, nav: INav, ) { LoadCityName(geohashStr) { cityName -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt index 6ad96aa160..5cc1d6b3e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt index 6cd258d289..3fe079db5a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt index 8f44e07eb9..e1e0609409 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt @@ -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 @@ -39,7 +39,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -58,19 +57,19 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplies import com.vitorpamplona.amethyst.ui.components.ClickableTextColor -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.ZapIcon import com.vitorpamplona.amethyst.ui.note.ZappedIcon +import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton +import com.vitorpamplona.amethyst.ui.note.buttons.PostButton import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.PostButton import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.placeholderText -import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.math.BigDecimal @@ -115,7 +114,7 @@ private fun RenderPledgeAmount( baseReward: Reward, accountViewModel: AccountViewModel, ) { - val repliesState by baseNote.live().replies.observeAsState() + val repliesState by observeNoteReplies(baseNote, accountViewModel) var reward by remember { mutableStateOf( showAmount(baseReward.amount), @@ -181,7 +180,6 @@ class AddBountyAmountViewModel : ViewModel() { newValue, bountyInner, draftTag = null, - relayList = myAccount.activeWriteRelays().toImmutableList(), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt index 2a59432966..2ff50fbc24 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt @@ -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 @@ -29,8 +29,9 @@ import androidx.compose.runtime.produceState import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment import com.vitorpamplona.amethyst.service.CachedRichTextParser import com.vitorpamplona.amethyst.ui.components.ClickableTextColor -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.quartz.nip01Core.core.Event @@ -43,9 +44,10 @@ import kotlinx.coroutines.launch fun DisplayUncitedHashtags( event: Event, callbackUri: String? = null, + accountViewModel: AccountViewModel, nav: INav, ) { - DisplayUncitedHashtags(event, event.content, callbackUri, nav) + DisplayUncitedHashtags(event, event.content, callbackUri, accountViewModel, nav) } @OptIn(ExperimentalLayoutApi::class) @@ -54,6 +56,7 @@ fun DisplayUncitedHashtags( event: Event, content: String, callbackUri: String? = null, + accountViewModel: AccountViewModel, nav: INav, ) { @Suppress("ProduceStateDoesNotAssignValue") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index 438b9ef451..ef1a115562 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -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 @@ -30,7 +30,6 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -42,16 +41,18 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserBookmarks +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserFollows import com.vitorpamplona.amethyst.ui.actions.EditPostView import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.components.GenericLoadable -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon import com.vitorpamplona.amethyst.ui.note.externalLinkForNote import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Size24Modifier @@ -185,7 +186,9 @@ fun NoteDropDownMenu( text = { Text(stringRes(R.string.copy_text)) }, onClick = { val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note - accountViewModel.decrypt(lastNoteVersion) { clipboardManager.setText(AnnotatedString(it)) } + accountViewModel.decrypt(lastNoteVersion) { + clipboardManager.setText(AnnotatedString(it)) + } onDismiss() }, ) @@ -270,7 +273,7 @@ fun NoteDropDownMenu( }, ) HorizontalDivider(thickness = DividerThickness) - if (accountViewModel.account.hasPendingAttestations(note)) { + if (accountViewModel.account.otsState.hasPendingAttestations(note)) { DropdownMenuItem( text = { Text(stringRes(R.string.timestamp_pending)) }, onClick = { @@ -322,34 +325,6 @@ fun NoteDropDownMenu( ) } HorizontalDivider(thickness = DividerThickness) - if (state.showSensitiveContent == null || state.showSensitiveContent == true) { - DropdownMenuItem( - text = { Text(stringRes(R.string.content_warning_hide_all_sensitive_content)) }, - onClick = { - accountViewModel.hideSensitiveContent() - onDismiss() - }, - ) - } - if (state.showSensitiveContent == null || state.showSensitiveContent == false) { - DropdownMenuItem( - text = { Text(stringRes(R.string.content_warning_show_all_sensitive_content)) }, - onClick = { - accountViewModel.disableContentWarnings() - onDismiss() - }, - ) - } - if (state.showSensitiveContent != null) { - DropdownMenuItem( - text = { Text(stringRes(R.string.content_warning_see_warnings)) }, - onClick = { - accountViewModel.seeContentWarnings() - onDismiss() - }, - ) - } - HorizontalDivider(thickness = DividerThickness) if (state.isLoggedUser) { DropdownMenuItem( text = { Text(stringRes(R.string.request_deletion)) }, @@ -380,39 +355,25 @@ fun WatchBookmarksFollowsAndAccount( accountViewModel: AccountViewModel, onNew: (DropDownParams) -> Unit, ) { - val followState by accountViewModel - .userProfile() - .live() - .follows - .observeAsState() - val bookmarkState by accountViewModel - .userProfile() - .live() - .bookmarks - .observeAsState() - val showSensitiveContent by accountViewModel - .showSensitiveContent() - .collectAsStateWithLifecycle() + val followState by observeUserFollows(accountViewModel.userProfile(), accountViewModel) + val bookmarkState by observeUserBookmarks(accountViewModel.userProfile(), accountViewModel) + val showSensitiveContent by accountViewModel.showSensitiveContent().collectAsStateWithLifecycle() LaunchedEffect(key1 = followState, key2 = bookmarkState, key3 = showSensitiveContent) { - launch(Dispatchers.IO) { - accountViewModel.isInPrivateBookmarks(note) { - val newState = - DropDownParams( - isFollowingAuthor = accountViewModel.isFollowing(note.author), - isPrivateBookmarkNote = it, - isPublicBookmarkNote = accountViewModel.isInPublicBookmarks(note), - isLoggedUser = accountViewModel.isLoggedUser(note.author), - isSensitive = note.event?.isSensitiveOrNSFW() ?: false, - showSensitiveContent = showSensitiveContent, - ) + val newState = + DropDownParams( + isFollowingAuthor = accountViewModel.isFollowing(note.author), + isPrivateBookmarkNote = accountViewModel.account.bookmarkState.isInPrivateBookmarks(note), + isPublicBookmarkNote = accountViewModel.account.bookmarkState.isInPublicBookmarks(note), + isLoggedUser = accountViewModel.isLoggedUser(note.author), + isSensitive = note.event?.isSensitiveOrNSFW() ?: false, + showSensitiveContent = showSensitiveContent, + ) - launch(Dispatchers.Main) { - onNew( - newState, - ) - } - } + launch(Dispatchers.Main) { + onNew( + newState, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt index 63a895ad81..b8fa57650c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt @@ -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,18 +27,20 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextOverflow import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUser +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.appendLink -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -80,10 +82,10 @@ fun ForkInformationRowLightColor( accountViewModel: AccountViewModel, nav: INav, ) { - val noteState by originalVersion.live().metadata.observeAsState() + val noteState by observeNote(originalVersion, accountViewModel) val note = noteState?.note ?: return val author = note.author ?: return - val route = remember(note) { routeFor(note, accountViewModel.userProfile()) } + val route = remember(note) { routeFor(note, accountViewModel.account) } if (route != null) { Row(modifier) { @@ -103,20 +105,16 @@ fun ForkInformationRowLightColor( overflow = TextOverflow.Visible, ) - val userState by author.live().metadata.observeAsState() - val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() } - val userTags = - remember(userState) { userState?.user?.info?.tags } - - if (userDisplayName != null) { + val userState by observeUser(author, accountViewModel) + userState?.user?.toBestDisplayName()?.let { CreateClickableTextWithEmoji( - clickablePart = userDisplayName, + clickablePart = it, maxLines = 1, route = route, overrideColor = MaterialTheme.colorScheme.nip05, fontSize = Font14SP, nav = nav, - tags = userTags, + tags = userState?.user?.info?.tags, ) } } @@ -130,19 +128,19 @@ fun ForkInformationRow( accountViewModel: AccountViewModel, nav: INav, ) { - val noteState by originalVersion.live().metadata.observeAsState() + val noteState by observeNote(originalVersion, accountViewModel) val note = noteState?.note ?: return - val route = remember(note) { routeFor(note, accountViewModel.userProfile()) } + val route = remember(note) { routeFor(note, accountViewModel.account) } if (route != null) { Row(modifier) { val author = note.author ?: return - val meta by author.live().userMetadataInfo.observeAsState(author.info) + val meta by observeUserInfo(author, accountViewModel) Text(stringRes(id = R.string.forked_from)) Spacer(modifier = StdHorzSpacer) - val userMetadata by author.live().userMetadataInfo.observeAsState() + val userMetadata by observeUserInfo(author, accountViewModel) CreateClickableTextWithEmoji( clickablePart = remember(meta) { meta?.bestName() ?: author.pubkeyDisplayHex() }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt index ef9db9c94b..197f09d62a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ZapTheDevsCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ZapTheDevsCard.kt index 958434b978..1826aa018c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ZapTheDevsCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ZapTheDevsCard.kt @@ -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,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.note.elements -import android.R.attr.onClick import android.content.Context import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Arrangement @@ -40,7 +39,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -65,13 +63,14 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.ZapPaymentHandler +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.appendLink -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.CloseIcon import com.vitorpamplona.amethyst.ui.note.ObserveZapIcon import com.vitorpamplona.amethyst.ui.note.PayViaIntentDialog @@ -162,7 +161,7 @@ fun ZapTheDevsCardPreview() { } """.trimIndent() - LocalCache.justConsume(Event.fromJson(releaseNotes), null) + LocalCache.justConsume(Event.fromJson(releaseNotes), null, false) } val accountViewModel = mockAccountViewModel() @@ -189,7 +188,7 @@ fun ZapTheDevsCard( accountViewModel: AccountViewModel, nav: INav, ) { - val releaseNoteState by baseNote.live().metadata.observeAsState() + val releaseNoteState by observeNote(baseNote, accountViewModel) val releaseNote = releaseNoteState?.note ?: return Row(modifier = Modifier.padding(start = Size10dp, end = Size10dp, bottom = Size10dp)) { @@ -237,7 +236,7 @@ fun ZapTheDevsCard( if (noteEvent != null) { val route = remember(releaseNote) { - routeFor(releaseNote, accountViewModel.userProfile()) + routeFor(releaseNote, accountViewModel.account) } if (route != null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt new file mode 100644 index 0000000000..4a5f30a01f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -0,0 +1,685 @@ +/** + * 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.ui.note.nip22Comments + +import android.content.Context +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.compose.currentWord +import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor +import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState +import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing +import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState +import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber +import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField +import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewState +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.IZapRaiser +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.IZapField +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.SplitBuilder +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.toZapSplitSetup +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent +import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash +import com.vitorpamplona.quartz.nip01Core.tags.geohash.hasGeohashes +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.references.references +import com.vitorpamplona.quartz.nip10Notes.content.findHashtags +import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris +import com.vitorpamplona.quartz.nip10Notes.content.findURLs +import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes +import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip22Comments.notify +import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji +import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag +import com.vitorpamplona.quartz.nip30CustomEmoji.emojis +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning +import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive +import com.vitorpamplona.quartz.nip37Drafts.DraftEvent +import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits +import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser +import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount +import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId +import com.vitorpamplona.quartz.nip73ExternalIds.scope +import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder +import com.vitorpamplona.quartz.nip92IMeta.imetas +import com.vitorpamplona.quartz.nip94FileMetadata.alt +import com.vitorpamplona.quartz.nip94FileMetadata.blurhash +import com.vitorpamplona.quartz.nip94FileMetadata.dims +import com.vitorpamplona.quartz.nip94FileMetadata.hash +import com.vitorpamplona.quartz.nip94FileMetadata.magnet +import com.vitorpamplona.quartz.nip94FileMetadata.mimeType +import com.vitorpamplona.quartz.nip94FileMetadata.originalHash +import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent +import com.vitorpamplona.quartz.nip94FileMetadata.size +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +@Stable +open class CommentPostViewModel : + ViewModel(), + ILocationGrabber, + IMessageField, + IZapField, + IZapRaiser { + val draftTag = DraftTagState() + + init { + viewModelScope.launch(Dispatchers.IO) { + draftTag.versions.collectLatest { + // don't save the first + if (it > 0) { + sendDraftSync() + } + } + } + } + + lateinit var accountViewModel: AccountViewModel + lateinit var account: Account + + var externalIdentity by mutableStateOf(null) + var replyingTo: Note? by mutableStateOf(null) + + val iMetaAttachments = IMetaAttachments() + var nip95attachments by mutableStateOf>>( + emptyList(), + ) + + var notifying by mutableStateOf?>(null) + + override var message by mutableStateOf(TextFieldValue("")) + + val urlPreviews = PreviewState() + + var isUploadingImage by mutableStateOf(false) + + var userSuggestions: UserSuggestionState? = null + var userSuggestionsMainMessage: UserSuggestionAnchor? = null + + var emojiSuggestions: EmojiSuggestionState? = null + + // Images and Videos + var multiOrchestrator by mutableStateOf(null) + + // Invoices + var canAddInvoice by mutableStateOf(false) + var wantsInvoice by mutableStateOf(false) + + var wantsSecretEmoji by mutableStateOf(false) + + // Forward Zap to + var wantsForwardZapTo by mutableStateOf(false) + override var forwardZapTo = mutableStateOf>(SplitBuilder()) + override var forwardZapToEditting = mutableStateOf(TextFieldValue("")) + + // NSFW, Sensitive + var wantsToMarkAsSensitive by mutableStateOf(false) + + // GeoHash + var wantsToAddGeoHash by mutableStateOf(false) + var location: StateFlow? = null + + // ZapRaiser + var canAddZapRaiser by mutableStateOf(false) + var wantsZapraiser by mutableStateOf(false) + override val zapRaiserAmount = mutableStateOf(null) + + fun lnAddress(): String? = account.userProfile().info?.lnAddress() + + fun hasLnAddress(): Boolean = account.userProfile().info?.lnAddress() != null + + fun user(): User? = account.userProfile() + + open fun init(accountVM: AccountViewModel) { + this.accountViewModel = accountVM + this.account = accountVM.account + this.canAddInvoice = hasLnAddress() + this.canAddZapRaiser = hasLnAddress() + + this.userSuggestions?.reset() + this.userSuggestions = UserSuggestionState(accountVM) + + this.emojiSuggestions?.reset() + this.emojiSuggestions = EmojiSuggestionState(accountVM) + } + + fun newPostFor(externalIdentity: ExternalId) { + this.externalIdentity = externalIdentity + } + + fun editFromDraft(draft: Note) { + val noteEvent = draft.event + val noteAuthor = draft.author + + if (noteEvent is DraftEvent && noteAuthor != null) { + viewModelScope.launch(Dispatchers.IO) { + accountViewModel.createTempDraftNote(noteEvent)?.let { innerNote -> + val oldTag = (draft.event as? AddressableEvent)?.dTag() + if (oldTag != null) { + draftTag.set(oldTag) + } + loadFromDraft(innerNote) + } + } + } + } + + open fun reply(post: Note) { + this.replyingTo = post + this.externalIdentity = (post.event as? CommentEvent)?.scope() + } + + open fun quote(quote: Note) { + message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}") + + quote.author?.let { quotedUser -> + if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) { + if (forwardZapTo.value.items.none { it.key.pubkeyHex == quotedUser.pubkeyHex }) { + forwardZapTo.value.addItem(quotedUser) + } + if (forwardZapTo.value.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) { + forwardZapTo.value.addItem(accountViewModel.userProfile()) + } + + val pos = forwardZapTo.value.items.indexOfFirst { it.key.pubkeyHex == quotedUser.pubkeyHex } + forwardZapTo.value.updatePercentage(pos, 0.9f) + } + } + + if (!forwardZapTo.value.items.isEmpty()) { + wantsForwardZapTo = true + } + + urlPreviews.update(message) + } + + private fun loadFromDraft(draft: Note) { + val draftEvent = draft.event ?: return + if (draftEvent !is CommentEvent) return + + loadFromDraft(draftEvent) + } + + private fun loadFromDraft(draftEvent: CommentEvent) { + val scope = draftEvent.scope() ?: return + this.externalIdentity = scope + + canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null + canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null + multiOrchestrator = null + + val localForwardZapTo = draftEvent.tags.filter { it.size > 1 && it[0] == "zap" } + forwardZapTo.value = SplitBuilder() + localForwardZapTo.forEach { + val user = LocalCache.getOrCreateUser(it[1]) + val value = it.last().toFloatOrNull() ?: 0f + forwardZapTo.value.addItem(user, value) + } + forwardZapToEditting.value = TextFieldValue("") + wantsForwardZapTo = localForwardZapTo.isNotEmpty() + + wantsToMarkAsSensitive = draftEvent.isSensitive() + + val zapraiser = draftEvent.zapraiserAmount() + wantsZapraiser = zapraiser != null + zapRaiserAmount.value = null + if (zapraiser != null) { + zapRaiserAmount.value = zapraiser + } + + draftEvent.replyingTo()?.let { + replyingTo = LocalCache.getOrCreateNote(it) + } + + wantsToAddGeoHash = draftEvent.hasGeohashes() + + notifying = draftEvent.rootAuthorKeys().mapNotNull { LocalCache.checkGetOrCreateUser(it) } + + draftEvent.replyAuthorKeys().mapNotNull { LocalCache.checkGetOrCreateUser(it) } + + if (forwardZapTo.value.items.isNotEmpty()) { + wantsForwardZapTo = true + } + + message = TextFieldValue(draftEvent.content) + + iMetaAttachments.addAll(draftEvent.imetas()) + + urlPreviews.update(message) + } + + suspend fun sendPostSync() { + val template = createTemplate() ?: return + val extraNotesToBroadcast = mutableListOf() + + if (nip95attachments.isNotEmpty()) { + val usedImages = template.tags.taggedQuoteIds().toSet() + nip95attachments.forEach { + if (usedImages.contains(it.second.id)) { + extraNotesToBroadcast.add(it.first) + extraNotesToBroadcast.add(it.second) + } + } + } + + val version = draftTag.current + + cancel() + + accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast) + accountViewModel.deleteDraft(version) + } + + suspend fun sendDraftSync() { + if (message.text.isBlank()) { + accountViewModel.account.deleteDraft(draftTag.current) + } else { + val template = createTemplate() ?: return + accountViewModel.account.createAndSendDraft(draftTag.current, template) + + nip95attachments.forEach { + account.sendToPrivateOutboxAndLocal(it.first) + account.sendToPrivateOutboxAndLocal(it.second) + } + } + } + + private suspend fun createTemplate(): EventTemplate? { + val tagger = + NewMessageTagger( + message = message.text, + dao = accountViewModel, + ) + tagger.run() + + val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() + + val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value) + val urls = findURLs(tagger.message) + val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) + + val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null + val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null + val contentWarningReason = if (wantsToMarkAsSensitive) "" else null + + val replyingTo = replyingTo + + val template = + if (replyingTo != null) { + val eventHint = replyingTo.toEventHint() ?: return null + + CommentEvent.replyBuilder( + msg = tagger.message, + replyingTo = eventHint, + ) { + tagger.pTags?.let { pTagList -> notify(pTagList.map { it.toPTag() }) } + + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + localZapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + contentWarningReason?.let { contentWarning(it) } + + emojis(emojis) + imetas(usedAttachments) + geoHash?.let { geohash(it) } + } + } else { + val externalIdentity = externalIdentity ?: return null + CommentEvent.replyExternalIdentity( + msg = tagger.message, + extId = externalIdentity, + ) { + tagger.pTags?.let { pTagList -> notify(pTagList.map { it.toPTag() }) } + + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + localZapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + contentWarningReason?.let { contentWarning(it) } + + emojis(emojis) + imetas(usedAttachments) + geoHash?.let { geohash(it) } + } + } + + return template + } + + fun findEmoji( + message: String, + myEmojiSet: List?, + ): List { + if (myEmojiSet == null) return emptyList() + return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> + myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { + EmojiUrlTag( + it.code, + it.link.url, + ) + } + } + } + + fun upload( + alt: String?, + contentWarningReason: String?, + mediaQuality: Int, + server: ServerName, + onError: (title: String, message: String) -> Unit, + context: Context, + ) = try { + uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context) + } catch (_: SignerExceptions.ReadOnlyException) { + onError( + stringRes(context, R.string.read_only_user), + stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events), + ) + } + + fun uploadUnsafe( + alt: String?, + contentWarningReason: String?, + mediaQuality: Int, + server: ServerName, + onError: (title: String, message: String) -> Unit, + context: Context, + ) { + viewModelScope.launch(Dispatchers.Default) { + val myMultiOrchestrator = multiOrchestrator ?: return@launch + + isUploadingImage = true + + val results = + myMultiOrchestrator.upload( + alt, + contentWarningReason, + MediaCompressor.Companion.intToCompressorQuality(mediaQuality), + server, + account, + context, + ) + + if (results.allGood) { + results.successful.forEach { state -> + if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) { + val nip95 = account.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, contentWarningReason) + nip95attachments = nip95attachments + nip95 + val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) } + + note?.let { + message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) + urlPreviews.update(message) + } + } else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { + val iMeta = + IMetaTagBuilder(state.result.url) + .apply { + hash(state.result.fileHeader.hash) + size(state.result.fileHeader.size) + state.result.fileHeader.mimeType + ?.let { mimeType(it) } + state.result.fileHeader.dim + ?.let { dims(it) } + state.result.fileHeader.blurHash + ?.let { blurhash(it.blurhash) } + state.result.magnet?.let { magnet(it) } + state.result.uploadedHash?.let { originalHash(it) } + + alt?.let { alt(it) } + contentWarningReason?.let { sensitiveContent(contentWarningReason) } + }.build() + + iMetaAttachments.replace(iMeta.url, iMeta) + + message = message.insertUrlAtCursor(state.result.url) + urlPreviews.update(message) + } + } + + multiOrchestrator = null + } else { + val errorMessages = + results.errors + .map { + stringRes( + context, + it.errorResource, + *it.params, + ) + }.distinct() + + onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) + } + + isUploadingImage = false + } + } + + open fun cancel() { + draftTag.rotate() + + message = TextFieldValue("") + + replyingTo = null + externalIdentity = null + + multiOrchestrator = null + isUploadingImage = false + + notifying = null + + wantsInvoice = false + wantsZapraiser = false + zapRaiserAmount.value = null + + wantsForwardZapTo = false + wantsToMarkAsSensitive = false + wantsToAddGeoHash = false + wantsSecretEmoji = false + + forwardZapTo.value = SplitBuilder() + forwardZapToEditting.value = TextFieldValue("") + + urlPreviews.reset() + + userSuggestions?.reset() + userSuggestionsMainMessage = null + + iMetaAttachments.reset() + + emojiSuggestions?.reset() + } + + fun deleteMediaToUpload(selected: SelectedMediaProcessing) { + this.multiOrchestrator?.remove(selected) + } + + open fun removeFromReplyList(userToRemove: User) { + notifying = notifying?.filter { it != userToRemove } + } + + override fun updateMessage(newMessage: TextFieldValue) { + message = newMessage + urlPreviews.update(message) + + if (message.selection.collapsed) { + val lastWord = message.currentWord() + + userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE + userSuggestions?.processCurrentWord(lastWord) + + emojiSuggestions?.processCurrentWord(lastWord) + } + + draftTag.newVersion() + } + + override fun updateZapForwardTo(newZapForwardTo: TextFieldValue) { + forwardZapToEditting.value = newZapForwardTo + if (newZapForwardTo.selection.collapsed) { + val lastWord = newZapForwardTo.text + userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS + userSuggestions?.processCurrentWord(lastWord) + } + } + + open fun autocompleteWithUser(item: User) { + userSuggestions?.let { userSuggestions -> + if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { + val lastWord = message.currentWord() + message = userSuggestions.replaceCurrentWord(message, lastWord, item) + urlPreviews.update(message) + } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { + forwardZapTo.value.addItem(item) + forwardZapToEditting.value = TextFieldValue("") + } + + userSuggestionsMainMessage = null + userSuggestions.reset() + } + + draftTag.newVersion() + } + + open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { + val wordToInsert = ":${item.code}:" + + message = message.replaceCurrentWord(wordToInsert) + urlPreviews.update(message) + + emojiSuggestions?.reset() + + draftTag.newVersion() + } + + open fun autocompleteWithEmojiUrl(item: EmojiPackState.EmojiMedia) { + val wordToInsert = item.link.url + " " + + viewModelScope.launch(Dispatchers.IO) { + iMetaAttachments.downloadAndPrepare(item.link.url) { + Amethyst.Companion.instance.okHttpClients + .getHttpClient( + accountViewModel.account.privacyState.shouldUseTorForImageDownload(item.link.url), + ) + } + } + + message = message.replaceCurrentWord(wordToInsert) + urlPreviews.update(message) + + emojiSuggestions?.reset() + + draftTag.newVersion() + } + + fun canPost(): Boolean = + message.text.isNotBlank() && + !isUploadingImage && + !wantsInvoice && + (!wantsZapraiser || zapRaiserAmount.value != null) && + multiOrchestrator == null + + fun insertAtCursor(newElement: String) { + message = message.insertUrlAtCursor(newElement) + } + + fun selectImage(uris: ImmutableList) { + multiOrchestrator = MultiOrchestrator(uris) + } + + override fun updateZapPercentage( + index: Int, + sliderValue: Float, + ) { + forwardZapTo.value.updatePercentage(index, sliderValue) + } + + override fun updateZapFromText() { + viewModelScope.launch(Dispatchers.Default) { + val tagger = + NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel) + tagger.run() + tagger.pTags?.forEach { taggedUser -> + if (!forwardZapTo.value.items.any { it.key == taggedUser }) { + forwardZapTo.value.addItem(taggedUser) + } + } + } + } + + override fun updateZapRaiserAmount(newAmount: Long?) { + zapRaiserAmount.value = newAmount + draftTag.newVersion() + } + + fun toggleMarkAsSensitive() { + wantsToMarkAsSensitive = !wantsToMarkAsSensitive + draftTag.newVersion() + } + + override fun locationFlow(): StateFlow { + if (location == null) { + location = locationManager().geohashStateFlow + } + + return location!! + } + + override fun locationManager(): LocationState = Amethyst.Companion.instance.locationManager +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayExternalId.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayExternalId.kt new file mode 100644 index 0000000000..da19175f11 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayExternalId.kt @@ -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.ui.note.nip22Comments + +import androidx.compose.runtime.Composable +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId +import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId + +@Composable +fun DisplayExternalId( + externalId: ExternalId, + accountViewModel: AccountViewModel, + nav: INav, +) { + when (externalId) { + is GeohashId -> DisplayGeohashExternalId(externalId, accountViewModel, nav) + is HashtagId -> DisplayHashtagExternalId(externalId, accountViewModel, nav) + else -> {} + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayGeoHashExternalId.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayGeoHashExternalId.kt new file mode 100644 index 0000000000..c8afa029e3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayGeoHashExternalId.kt @@ -0,0 +1,96 @@ +/** + * 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.ui.note.nip22Comments + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.LinkInteractionListener +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withLink +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId + +@Composable +fun DisplayGeohashExternalId( + externalId: GeohashId, + accountViewModel: AccountViewModel, + nav: INav, +) { + DisplayGeohashExternalId(externalId.geohash) { + nav.nav(Route.Geohash(externalId.geohash)) + } +} + +@Composable +fun DisplayGeohashExternalId( + geohash: String, + linkInteractionListener: LinkInteractionListener, +) { + Row(modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) { + LoadCityName(geohash) { cityName -> + Icon( + imageVector = Icons.Default.LocationOn, + contentDescription = stringRes(id = R.string.geohash_exclusive), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + Spacer(StdHorzSpacer) + + Text( + text = + buildAnnotatedString { + withLink( + LinkAnnotation.Clickable("cityname", null, linkInteractionListener), + ) { + append(cityName) + } + }, + style = + LocalTextStyle.current.copy( + fontWeight = FontWeight.Bold, + ), + maxLines = 1, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayHashtagExternalId.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayHashtagExternalId.kt new file mode 100644 index 0000000000..824e380047 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayHashtagExternalId.kt @@ -0,0 +1,93 @@ +/** + * 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.ui.note.nip22Comments + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Tag +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.LinkInteractionListener +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withLink +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId + +@Composable +fun DisplayHashtagExternalId( + externalId: HashtagId, + accountViewModel: AccountViewModel, + nav: INav, +) { + DisplayHashtagExternalId(externalId.topic) { + nav.nav(Route.Hashtag(externalId.topic)) + } +} + +@Composable +fun DisplayHashtagExternalId( + topic: String, + linkInteractionListener: LinkInteractionListener, +) { + Row(modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Tag, + contentDescription = stringRes(id = R.string.hashtag_exclusive), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + Spacer(StdHorzSpacer) + + Text( + text = + buildAnnotatedString { + withLink( + LinkAnnotation.Clickable("hashtag", null, linkInteractionListener), + ) { + append(topic) + } + }, + style = + LocalTextStyle.current.copy( + fontWeight = FontWeight.Bold, + ), + maxLines = 1, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt new file mode 100644 index 0000000000..d05efddcea --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -0,0 +1,414 @@ +/** + * 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.ui.note.nip22Comments + +import android.net.Uri +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar +import com.vitorpamplona.amethyst.ui.note.BaseUserPicture +import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer +import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList +import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton +import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest +import com.vitorpamplona.amethyst.ui.note.creators.location.AddGeoHashButton +import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash +import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField +import com.vitorpamplona.amethyst.ui.note.creators.notify.Notifying +import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews +import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton +import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest +import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.AddZapraiserButton +import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.ZapRaiserRequest +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapTo +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapToButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Composable +fun ReplyCommentPostScreen( + reply: Note? = null, + message: String? = null, + attachment: Uri? = null, + quote: Note? = null, + draft: Note? = null, + accountViewModel: AccountViewModel, + nav: Nav, +) { + val postViewModel: CommentPostViewModel = viewModel() + postViewModel.init(accountViewModel) + + val context = LocalContext.current + + LaunchedEffect(Unit) { + reply?.let { + postViewModel.reply(it) + } + draft?.let { + postViewModel.editFromDraft(it) + } + quote?.let { + postViewModel.quote(it) + } + message?.ifBlank { null }?.let { + postViewModel.updateMessage(TextFieldValue(it)) + } + attachment?.let { + withContext(Dispatchers.IO) { + val mediaType = context.contentResolver.getType(it) + postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) + } + } + } + + GenericCommentPostScreen(postViewModel, accountViewModel, nav) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GenericCommentPostScreen( + postViewModel: CommentPostViewModel, + accountViewModel: AccountViewModel, + nav: Nav, +) { + WatchAndLoadMyEmojiList(accountViewModel) + + Scaffold( + topBar = { + PostingTopBar( + isActive = postViewModel::canPost, + onCancel = { + // uses the accountViewModel scope to avoid cancelling this + // function when the postViewModel is released + accountViewModel.viewModelScope.launch(Dispatchers.IO) { + postViewModel.sendDraftSync() + nav.popBack() + postViewModel.cancel() + } + }, + onPost = { + // uses the accountViewModel scope to avoid cancelling this + // function when the postViewModel is released + accountViewModel.viewModelScope.launch(Dispatchers.IO) { + postViewModel.sendPostSync() + nav.popBack() + } + }, + ) + }, + ) { pad -> + Surface( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .imePadding(), + ) { + GenericCommentPostBody( + postViewModel, + accountViewModel, + nav, + ) + } + } +} + +@Composable +private fun GenericCommentPostBody( + postViewModel: CommentPostViewModel, + accountViewModel: AccountViewModel, + nav: Nav, +) { + val scrollState = rememberScrollState() + + Column(Modifier.fillMaxSize()) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding( + start = Size10dp, + end = Size10dp, + ).weight(1f), + ) { + Column(Modifier.fillMaxWidth().verticalScroll(scrollState)) { + postViewModel.externalIdentity?.let { + Row { + DisplayExternalId(it, accountViewModel, nav) + Spacer(modifier = StdVertSpacer) + } + } + + postViewModel.replyingTo?.let { + Row { + NoteCompose( + baseNote = it, + modifier = MaterialTheme.colorScheme.replyModifier, + isQuotedNote = true, + unPackReply = false, + makeItShort = true, + quotesLeft = 1, + accountViewModel = accountViewModel, + nav = nav, + ) + Spacer(modifier = StdVertSpacer) + } + } + + Row { + Notifying(postViewModel.notifying?.toImmutableList(), accountViewModel) { + postViewModel.removeFromReplyList(it) + } + } + + Row( + modifier = Modifier.padding(vertical = Size10dp), + ) { + BaseUserPicture( + accountViewModel.userProfile(), + Size35dp, + accountViewModel = accountViewModel, + ) + MessageField( + R.string.what_s_on_your_mind, + postViewModel, + ) + } + + DisplayPreviews(postViewModel.urlPreviews, accountViewModel, nav) + + if (postViewModel.wantsToMarkAsSensitive) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + ContentSensitivityExplainer() + } + } + + if (postViewModel.wantsToAddGeoHash) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + LocationAsHash(postViewModel) + } + } + + if (postViewModel.wantsForwardZapTo) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(top = Size5dp, bottom = Size5dp, start = Size10dp), + ) { + ForwardZapTo(postViewModel, accountViewModel) + } + } + + postViewModel.multiOrchestrator?.let { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + val context = LocalContext.current + ImageVideoDescription( + it, + accountViewModel.account.settings.defaultFileServer, + onAdd = { alt, server, sensitiveContent, mediaQuality -> + postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context) + if (server.type != ServerType.NIP95) { + accountViewModel.account.settings.changeDefaultFileServer(server) + } + }, + onDelete = postViewModel::deleteMediaToUpload, + onCancel = { postViewModel.multiOrchestrator = null }, + accountViewModel = accountViewModel, + ) + } + } + + if (postViewModel.wantsInvoice) { + postViewModel.lnAddress()?.let { lud16 -> + InvoiceRequest( + lud16, + accountViewModel.account.userProfile(), + accountViewModel, + stringRes(id = R.string.lightning_invoice), + stringRes(id = R.string.lightning_create_and_add_invoice), + onNewInvoice = { + postViewModel.insertAtCursor(it) + postViewModel.wantsInvoice = false + }, + onError = { title, message -> accountViewModel.toastManager.toast(title, message) }, + ) + } + } + + if (postViewModel.wantsSecretEmoji) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + Column(Modifier.fillMaxWidth()) { + SecretEmojiRequest { + postViewModel.insertAtCursor(it) + postViewModel.wantsSecretEmoji = false + } + } + } + } + + if (postViewModel.wantsZapraiser && postViewModel.hasLnAddress()) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + ZapRaiserRequest( + stringRes(id = R.string.zapraiser), + postViewModel, + ) + } + } + } + } + + postViewModel.userSuggestions?.let { + ShowUserSuggestionList( + it, + postViewModel::autocompleteWithUser, + accountViewModel, + modifier = Modifier.heightIn(0.dp, 300.dp), + ) + } + + postViewModel.emojiSuggestions?.let { + ShowEmojiSuggestionList( + it, + postViewModel::autocompleteWithEmoji, + postViewModel::autocompleteWithEmojiUrl, + accountViewModel, + modifier = Modifier.heightIn(0.dp, 300.dp), + ) + } + + BottomRowActions(postViewModel) + } +} + +@Composable +private fun BottomRowActions(postViewModel: CommentPostViewModel) { + val scrollState = rememberScrollState() + Row( + modifier = + Modifier + .horizontalScroll(scrollState) + .fillMaxWidth() + .height(50.dp), + verticalAlignment = CenterVertically, + ) { + SelectFromGallery( + isUploading = postViewModel.isUploadingImage, + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier, + ) { + postViewModel.selectImage(it) + } + + TakePictureButton( + onPictureTaken = { + postViewModel.selectImage(it) + }, + ) + + ForwardZapToButton(postViewModel.wantsForwardZapTo) { + postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo + } + + if (postViewModel.canAddZapRaiser) { + AddZapraiserButton(postViewModel.wantsZapraiser) { + postViewModel.wantsZapraiser = !postViewModel.wantsZapraiser + } + } + + MarkAsSensitiveButton(postViewModel.wantsToMarkAsSensitive) { + postViewModel.toggleMarkAsSensitive() + } + + AddGeoHashButton(postViewModel.wantsToAddGeoHash) { + postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash + } + + AddSecretEmojiButton(postViewModel.wantsSecretEmoji) { + postViewModel.wantsSecretEmoji = !postViewModel.wantsSecretEmoji + } + + if (postViewModel.canAddInvoice && postViewModel.hasLnAddress()) { + AddLnInvoiceButton(postViewModel.wantsInvoice) { + postViewModel.wantsInvoice = !postViewModel.wantsInvoice + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt index 10bbeb5370..f690866fdf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt @@ -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,7 +49,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -62,8 +61,9 @@ import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LinkIcon +import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size16Modifier @@ -122,7 +122,7 @@ fun RenderAppDefinition( } } else { Image( - painter = painterResource(R.drawable.profile_banner), + painter = painterRes(R.drawable.profile_banner, 6), contentDescription = stringRes(id = R.string.profile_banner), contentScale = ContentScale.FillWidth, modifier = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt index 472b4ad951..718b6e378a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt @@ -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 @@ -45,9 +45,10 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.playback.composable.LoadThumbAndThenVideoView import com.vitorpamplona.amethyst.service.playback.composable.VideoView +import com.vitorpamplona.amethyst.service.playback.composable.WaveformData import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags @@ -183,7 +184,7 @@ fun AudioHeader( nav: INav, ) { val media = remember { noteEvent.stream() ?: noteEvent.download() } - val waveform = remember { noteEvent.wavefrom() } + val waveform = remember { noteEvent.wavefrom()?.let { WaveformData(it.wave) } } val content = remember { noteEvent.content.ifBlank { null } } val defaultBackground = MaterialTheme.colorScheme.background @@ -236,7 +237,7 @@ fun AudioHeader( if (noteEvent.hasHashtags()) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - DisplayUncitedHashtags(noteEvent, callbackUri, nav) + DisplayUncitedHashtags(noteEvent, callbackUri, accountViewModel, nav) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt index 366bd79469..7b770f447c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt @@ -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 @@ -33,7 +33,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -47,7 +46,8 @@ import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -57,17 +57,21 @@ import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent @Composable -fun BadgeDisplay(baseNote: Note) { - val observingNote by baseNote.live().metadata.observeAsState() - val badgeData = observingNote?.note?.event as? BadgeDefinitionEvent ?: return +fun BadgeDisplay( + baseNote: Note, + accountViewModel: AccountViewModel, +) { + val badgeData by observeNoteEvent(baseNote, accountViewModel) - RenderBadge( - badgeData.image(), - badgeData.name(), - MaterialTheme.colorScheme.background, - MaterialTheme.colorScheme.onBackground, - badgeData.description(), - ) + badgeData?.let { + RenderBadge( + it.image(), + it.name(), + MaterialTheme.colorScheme.background, + MaterialTheme.colorScheme.onBackground, + it.description(), + ) + } } @Preview @@ -173,6 +177,6 @@ fun RenderBadgeAward( } note.replyTo?.firstOrNull()?.let { - BadgeDisplay(baseNote = it) + BadgeDisplay(baseNote = it, accountViewModel) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelMessage.kt index f6b9111864..9709ac8906 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelMessage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelMessage.kt @@ -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 @@ -31,9 +31,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.GenericLoadable -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.amethyst.ui.screen.loggedIn.chats.publicChannels.ChannelHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelHeader import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent @@ -60,9 +60,8 @@ fun RenderChannelMessage( } showChannelInfo?.let { - ChannelHeader( + PublicChatChannelHeader( channelHex = it, - showVideo = false, sendToChannel = true, modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), accountViewModel = accountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessage.kt index 4b66a1e197..3c75950cd4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessage.kt @@ -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 @@ -33,8 +33,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.GenericLoadable -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.ChatroomHeader import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer @@ -62,7 +62,7 @@ fun RenderChatMessage( userRoom?.let { if (it.users.size > 1 || (it.users.size == 1 && note.author == accountViewModel.account.userProfile())) { ChatroomHeader(it, MaterialTheme.colorScheme.replyModifier.padding(10.dp), accountViewModel) { - routeFor(note, accountViewModel.userProfile())?.let { + routeFor(note, accountViewModel.account)?.let { nav.nav(it) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessageEncryptedFile.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessageEncryptedFile.kt index 3a2f8c5969..0b20944483 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessageEncryptedFile.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessageEncryptedFile.kt @@ -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 @@ -35,8 +35,8 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.SensitivityWarning -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderEncryptedFile import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.ChatroomHeader @@ -66,7 +66,7 @@ fun RenderChatMessageEncryptedFile( userRoom?.let { if (it.users.size > 1 || (it.users.size == 1 && note.author == accountViewModel.account.userProfile())) { ChatroomHeader(it, MaterialTheme.colorScheme.replyModifier.padding(10.dp), accountViewModel) { - routeFor(note, accountViewModel.userProfile())?.let { + routeFor(note, accountViewModel.account)?.let { nav.nav(it) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt index b6b5e97c79..b4e0ab5bf1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt @@ -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 @@ -38,19 +38,20 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid +import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.SmallBorder import com.vitorpamplona.amethyst.ui.theme.subtleBorder import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import kotlinx.collections.immutable.toImmutableList @Composable fun RenderClassifieds( @@ -59,12 +60,22 @@ fun RenderClassifieds( accountViewModel: AccountViewModel, nav: INav, ) { - val image = remember(noteEvent) { noteEvent.image() } - val title = remember(noteEvent) { noteEvent.title() } - val summary = - remember(noteEvent) { noteEvent.summary() ?: noteEvent.content.take(200).ifBlank { null } } - val price = remember(noteEvent) { noteEvent.price() } - val location = remember(noteEvent) { noteEvent.location() } + val imageSet = + noteEvent.imageMetas().ifEmpty { null }?.map { + MediaUrlImage( + url = it.url, + description = it.alt, + hash = it.hash, + blurhash = it.blurhash, + dim = it.dimension, + uri = note.toNostrUri(), + mimeType = it.mimeType, + ) + } + val title = noteEvent.title() + val summary = noteEvent.summary() ?: noteEvent.content.take(200).ifBlank { null } + val price = noteEvent.price() + val location = noteEvent.location() Row( modifier = @@ -78,19 +89,18 @@ fun RenderClassifieds( ) { Column { Row { - image?.let { - AsyncImage( - model = it, - contentDescription = - stringRes( - R.string.preview_card_image_for, - it, - ), - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth(), - ) + imageSet?.let { images -> + AutoNonlazyGrid(images.size) { + ZoomableContentView( + content = images[it], + images = images.toImmutableList(), + roundedCorner = false, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } } ?: run { - DefaultImageHeader(note, accountViewModel) + DefaultImageHeader(note, accountViewModel, Modifier.fillMaxWidth()) } } @@ -100,7 +110,7 @@ fun RenderClassifieds( ) { title?.let { Text( - text = it, + text = "test'", style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt index 588badc4a5..60346dd4a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt @@ -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 @@ -35,7 +35,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -52,10 +51,12 @@ import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav.scope +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture @@ -100,7 +101,7 @@ fun RenderCommunity( Row( MaterialTheme.colorScheme.innerPostModifier .clickable { - routeFor(baseNote, accountViewModel.userProfile())?.let { nav.nav(it) } + routeFor(baseNote, accountViewModel.account)?.let { nav.nav(it) } }.padding(Size10dp), ) { ShortCommunityHeader( @@ -119,19 +120,17 @@ fun LongCommunityHeader( accountViewModel: AccountViewModel, nav: INav, ) { - val noteState by baseNote.live().metadata.observeAsState() - val noteEvent = - remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return + val noteEvent by observeNoteEvent(baseNote, accountViewModel) Row( lineModifier, ) { val rulesLabel = stringRes(id = R.string.rules) val summary = - remember(noteState) { - val subject = noteEvent.subject()?.ifEmpty { null } - val body = noteEvent.description()?.ifBlank { null } - val rules = noteEvent.rules()?.ifBlank { null } + remember(noteEvent) { + val subject = noteEvent?.subject()?.ifEmpty { null } + val body = noteEvent?.description()?.ifBlank { null } + val rules = noteEvent?.rules()?.ifBlank { null } if (!subject.isNullOrBlank() && body?.split("\n")?.get(0)?.contains(subject) == false) { if (rules == null) { @@ -167,12 +166,15 @@ fun LongCommunityHeader( ) } - if (summary != null && noteEvent.hasHashtags()) { - DisplayUncitedHashtags( - event = noteEvent, - content = summary, - nav = nav, - ) + noteEvent?.let { + if (it.hasHashtags()) { + DisplayUncitedHashtags( + event = it, + content = summary ?: "", + accountViewModel = accountViewModel, + nav = nav, + ) + } } } @@ -195,7 +197,7 @@ fun LongCommunityHeader( modifier = Modifier.width(75.dp), ) Spacer(DoubleHorzSpacer) - NoteAuthorPicture(baseNote, nav, accountViewModel, Size25dp) + NoteAuthorPicture(baseNote, Size25dp, accountViewModel = accountViewModel, nav = nav) Spacer(DoubleHorzSpacer) NoteUsernameDisplay(baseNote, Modifier.weight(1f), accountViewModel = accountViewModel) } @@ -207,14 +209,12 @@ fun LongCommunityHeader( ) } - LaunchedEffect(key1 = noteState) { - val participants = (noteState?.note?.event as? CommunityDefinitionEvent)?.moderators() + LaunchedEffect(key1 = noteEvent) { + val participants = noteEvent?.moderators() if (participants != null) { accountViewModel.loadParticipants(participants) { newParticipantUsers -> - if ( - newParticipantUsers != null && !equalImmutableLists(newParticipantUsers, participantUsers) - ) { + if (!equalImmutableLists(newParticipantUsers, participantUsers)) { participantUsers = newParticipantUsers } } @@ -228,7 +228,10 @@ fun LongCommunityHeader( ) { it.first.role?.let { it1 -> Text( - text = it1.capitalize(Locale.ROOT), + text = + it1.replaceFirstChar { + if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() + }, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(75.dp), @@ -263,12 +266,10 @@ fun ShortCommunityHeader( accountViewModel: AccountViewModel, nav: INav, ) { - val noteState by baseNote.live().metadata.observeAsState() - val noteEvent = - remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return + val noteEvent by observeNoteEvent(baseNote, accountViewModel) Row(verticalAlignment = Alignment.CenterVertically) { - noteEvent.image()?.let { + noteEvent?.image()?.let { RobohashFallbackAsyncImage( robot = baseNote.idHex, model = it.imageUrl, @@ -290,7 +291,7 @@ fun ShortCommunityHeader( ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = remember(noteState) { noteEvent.dTag() }, + text = noteEvent?.name() ?: baseNote.dTag(), maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -361,9 +362,10 @@ fun WatchAddressableNoteFollows( accountViewModel: AccountViewModel, onFollowChanges: @Composable (Boolean) -> Unit, ) { - val state by accountViewModel.account.liveKind3Follows.collectAsStateWithLifecycle() + val state by accountViewModel.account.kind3FollowList.flow + .collectAsStateWithLifecycle() - onFollowChanges(state.addresses.contains(note.idHex)) + onFollowChanges(state.communities.contains(note.idHex)) } @Composable @@ -372,11 +374,13 @@ fun JoinCommunityButton( note: AddressableNote, nav: INav, ) { - val scope = rememberCoroutineScope() - Button( modifier = Modifier.padding(horizontal = 3.dp), - onClick = { scope.launch(Dispatchers.IO) { accountViewModel.account.follow(note) } }, + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.follow(note) + } + }, shape = ButtonBorder, colors = ButtonDefaults.buttonColors( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt index df9712d1d1..844d9e47cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt @@ -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,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -46,10 +45,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.lifecycle.distinctUntilChanged -import androidx.lifecycle.map import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.ShowMoreButton import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote @@ -71,17 +70,9 @@ public fun RenderEmojiPack( accountViewModel: AccountViewModel, onClick: ((EmojiUrlTag) -> Unit)? = null, ) { - val noteEvent by - baseNote - .live() - .metadata - .map { it.note.event } - .distinctUntilChanged() - .observeAsState(baseNote.event) + val noteEvent by observeNoteEvent(baseNote, accountViewModel) - if (noteEvent == null || noteEvent !is EmojiPackEvent) return - - (noteEvent as? EmojiPackEvent)?.let { + noteEvent?.let { RenderEmojiPack( noteEvent = it, baseNote = baseNote, @@ -184,24 +175,19 @@ private fun EmojiListOptions( emojiPackNote: Note, ) { LoadAddressableNote( - accountViewModel.account.getEmojiPackSelectionAddress(), + accountViewModel.account.emoji.getEmojiPackSelectionAddress(), accountViewModel, ) { it?.let { usersEmojiList -> - val hasAddedThis by - remember { - usersEmojiList - .live() - .metadata - .map { usersEmojiList.event?.isTaggedAddressableNote(emojiPackNote.idHex) } - .distinctUntilChanged() - }.observeAsState() + val hasAddedThis by observeNoteAndMap(usersEmojiList, accountViewModel) { + usersEmojiList.event?.isTaggedAddressableNote(emojiPackNote.idHex) + } CrossfadeIfEnabled(targetState = hasAddedThis, label = "EmojiListOptions", accountViewModel = accountViewModel) { if (it != true) { - AddButton { accountViewModel.addEmojiPack(usersEmojiList, emojiPackNote) } + AddButton(modifier = Modifier.padding(start = 3.dp)) { accountViewModel.addEmojiPack(emojiPackNote) } } else { - RemoveButton { accountViewModel.removeEmojiPack(usersEmojiList, emojiPackNote) } + RemoveButton(modifier = Modifier.padding(start = 3.dp)) { accountViewModel.removeEmojiPack(emojiPackNote) } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt index cb69b0d895..0705aea284 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt index 02c39c05bb..7b3ea345f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt @@ -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,16 +22,15 @@ package com.vitorpamplona.amethyst.ui.note.types import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent import com.vitorpamplona.amethyst.commons.richtext.MediaLocalImage import com.vitorpamplona.amethyst.commons.richtext.MediaLocalVideo import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.SensitivityWarning @@ -68,9 +67,7 @@ private fun ObserverAndRenderNIP95( ) { val eventHeader = (header.event as? FileStorageHeaderEvent) ?: return - val appContext = LocalContext.current.applicationContext - - val noteState by content.live().metadata.observeAsState() + val noteState by observeNote(content, accountViewModel) val content by remember(noteState) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FollowList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FollowList.kt new file mode 100644 index 0000000000..ec18a8c735 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FollowList.kt @@ -0,0 +1,159 @@ +/** + * 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.ui.note.types + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage +import com.vitorpamplona.amethyst.ui.components.ShowMoreButton +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.UserCompose +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground +import com.vitorpamplona.amethyst.ui.note.getGradient +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FollowSetImageModifier +import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun DisplayFollowList( + baseNote: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = baseNote.event as? FollowListEvent ?: return + + var members by remember { mutableStateOf>(persistentListOf()) } + + var expanded by remember { mutableStateOf(false) } + + val toMembersShow = + if (expanded) { + members + } else { + members.take(3) + } + + val image = noteEvent.image() + + image?.let { + MyAsyncImage( + imageUrl = it, + contentDescription = + stringRes( + R.string.preview_card_image_for, + it, + ), + contentScale = ContentScale.Crop, + mainImageModifier = Modifier.fillMaxWidth(), + loadedImageModifier = FollowSetImageModifier, + accountViewModel = accountViewModel, + onLoadingBackground = { DefaultImageHeaderBackground(baseNote, accountViewModel) }, + onError = { DefaultImageHeader(baseNote, accountViewModel) }, + ) + } ?: run { + DefaultImageHeader(baseNote, accountViewModel, FollowSetImageModifier) + } + + Text( + text = noteEvent.title() ?: noteEvent.dTag(), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 10.dp), + textAlign = TextAlign.Center, + ) + + LaunchedEffect(Unit) { + accountViewModel.loadUsers(noteEvent.taggedUserIds()) { + members = it + } + } + + Box { + FlowRow(modifier = Modifier.padding(top = 5.dp)) { + toMembersShow.forEach { user -> + Column(modifier = Modifier.fillMaxWidth()) { + UserCompose( + user, + accountViewModel = accountViewModel, + nav = nav, + ) + + HorizontalDivider( + thickness = DividerThickness, + ) + } + } + } + + if (members.size > 3 && !expanded) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .background(getGradient(backgroundColor)), + ) { + ShowMoreButton { expanded = !expanded } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt index 17387b2236..bf5aad6cb9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt @@ -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 @@ -33,7 +33,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -43,10 +42,11 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.components.ClickableUrl import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags @@ -100,13 +100,12 @@ private fun RenderShortRepositoryHeader( accountViewModel: AccountViewModel, nav: INav, ) { - val noteState by baseNote.live().metadata.observeAsState() - val noteEvent = noteState?.note?.event as? GitRepositoryEvent ?: return + val noteEvent by observeNoteEvent(baseNote, accountViewModel) Column( modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), ) { - val title = remember(noteEvent) { noteEvent.name() ?: noteEvent.dTag() } + val title = noteEvent?.name() ?: baseNote.dTag() Text( text = stringRes(id = R.string.git_repository, title), style = MaterialTheme.typography.titleLarge, @@ -115,7 +114,7 @@ private fun RenderShortRepositoryHeader( modifier = Modifier.fillMaxWidth(), ) - noteEvent.description()?.let { + noteEvent?.description()?.let { Spacer(modifier = DoubleVertSpacer) Text( text = it, @@ -200,6 +199,7 @@ private fun RenderGitPatchEvent( event = noteEvent, content = eventContent, callbackUri = callbackUri, + accountViewModel = accountViewModel, nav = nav, ) } @@ -302,7 +302,7 @@ private fun RenderGitIssueEvent( } if (note.event?.hasHashtags() == true) { - DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, nav) + DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, accountViewModel, nav) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt index d246ea254a..d25ae7476a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt @@ -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 @@ -32,7 +32,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -41,6 +40,8 @@ import androidx.compose.ui.graphics.Color import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary import com.vitorpamplona.amethyst.ui.components.ClickableUrl import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji @@ -48,8 +49,8 @@ import com.vitorpamplona.amethyst.ui.components.DisplayEvent import com.vitorpamplona.amethyst.ui.components.RenderUserAsClickableText import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.measureSpaceWidth -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.firstTagValueFor import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address @@ -74,6 +75,7 @@ fun RenderHighlight( val noteEvent = note.event as? HighlightEvent ?: return DisplayHighlight( + comment = noteEvent.comment(), highlight = noteEvent.quote(), context = noteEvent.context(), authorHex = noteEvent.pubKey, @@ -92,6 +94,7 @@ fun RenderHighlight( @OptIn(ExperimentalLayoutApi::class) @Composable fun DisplayHighlight( + comment: String?, highlight: String, context: String?, authorHex: String?, @@ -105,6 +108,21 @@ fun DisplayHighlight( accountViewModel: AccountViewModel, nav: INav, ) { + comment?.let { + TranslatableRichTextViewer( + content = it, + canPreview = canPreview && !makeItShort, + quotesLeft = quotesLeft, + modifier = Modifier.fillMaxWidth(), + tags = EmptyTagList, + backgroundColor = backgroundColor, + id = it, + callbackUri = null, + accountViewModel = accountViewModel, + nav = nav, + ) + } + val quote = remember { highlight.split("\n").joinToString("\n") { "> *${it.removeSuffix(" ")}*" } @@ -218,7 +236,7 @@ fun DisplayEntryForUser( accountViewModel: AccountViewModel, nav: INav, ) { - val userMetadata by baseUser.live().userMetadataInfo.observeAsState() + val userMetadata by observeUserInfo(baseUser, accountViewModel) CreateClickableTextWithEmoji( clickablePart = userMetadata?.bestName() ?: baseUser.pubkeyDisplayHex(), @@ -236,15 +254,15 @@ fun DisplayEntryForNote( accountViewModel: AccountViewModel, nav: INav, ) { - val noteState by note.live().metadata.observeAsState() + val noteState by observeNote(note, accountViewModel) val author = userBase ?: noteState?.note?.author if (author != null) { - RenderUserAsClickableText(author, null, nav) + RenderUserAsClickableText(author, null, accountViewModel, nav) } - val noteEvent = noteState?.note?.event as? BaseThreadedEvent ?: return + val noteEvent = noteState.note.event as? BaseThreadedEvent ?: return val description = remember(noteEvent) { noteEvent.tags.firstTagValueFor("title", "subject", "alt") } @@ -253,10 +271,10 @@ fun DisplayEntryForNote( if (description != null) { ClickableTextPrimary( text = description, - onClick = { routeFor(note, accountViewModel.userProfile())?.let { nav.nav(it) } }, + onClick = { routeFor(note, accountViewModel.account)?.let { nav.nav(it) } }, ) } else { - DisplayEvent(noteEvent.id, noteEvent.kind, note.toNostrUri(), null, accountViewModel, nav) + DisplayEvent(noteEvent.id, note.toNostrUri(), null, accountViewModel, nav) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/InteractiveStory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/InteractiveStory.kt index de6814f1a7..cf124de8f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/InteractiveStory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/InteractiveStory.kt @@ -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 @@ -29,7 +29,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable -import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -39,8 +39,11 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent @@ -62,38 +65,38 @@ fun RenderInteractiveStory( val address = baseNote.address() ?: return // keep updating the root event with new versions - val note = baseNote.live().metadata.observeAsState() + val note = observeNote(baseNote, accountViewModel) val rootEvent = note.value?.note?.event as? InteractiveStoryBaseEvent ?: return // keep updating the reading state event with new versions val readingStateNote = accountViewModel.getInteractiveStoryReadingState(address.toValue()) - val latestReadingNoteState = readingStateNote.live().metadata.observeAsState() - val readingState = latestReadingNoteState.value?.note?.event as? InteractiveStoryReadingStateEvent + val readingState by observeNoteEvent(readingStateNote, accountViewModel) val currentScene = readingState?.currentScene() if (currentScene != null && currentScene != rootEvent.address()) { LoadAddressableNote(currentScene, accountViewModel) { currentSceneBaseNote -> - val currentScene = currentSceneBaseNote?.live()?.metadata?.observeAsState() - val currentSceneEvent = currentScene?.value?.note?.event as? InteractiveStoryBaseEvent + currentSceneBaseNote?.let { + val currentSceneEvent by observeNoteEvent(it, accountViewModel) - if (currentSceneEvent != null) { - RenderInteractiveStory( - section = currentSceneEvent, - onSelect = { - val event = it.event as? InteractiveStoryBaseEvent ?: return@RenderInteractiveStory - accountViewModel.updateInteractiveStoryReadingState(baseRootEvent, event) - }, - onRestart = { - accountViewModel.updateInteractiveStoryReadingState(baseRootEvent, rootEvent) - }, - makeItShort = makeItShort, - canPreview = canPreview, - quotesLeft = quotesLeft, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) + currentSceneEvent?.let { + RenderInteractiveStory( + section = it, + onSelect = { + val event = it.event as? InteractiveStoryBaseEvent ?: return@RenderInteractiveStory + accountViewModel.updateInteractiveStoryReadingState(baseRootEvent, event) + }, + onRestart = { + accountViewModel.updateInteractiveStoryReadingState(baseRootEvent, rootEvent) + }, + makeItShort = makeItShort, + canPreview = canPreview, + quotesLeft = quotesLeft, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } } } } else { @@ -163,7 +166,7 @@ fun RenderInteractiveStory( options.forEach { opt -> LoadAddressableNote(opt.address, accountViewModel) { note -> if (note != null) { - val optionState = note.live().metadata.observeAsState() + EventFinderFilterAssemblerSubscription(note, accountViewModel) OutlinedButton( onClick = { onSelect(note) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt index a3251bba9a..90949d90cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt @@ -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,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -45,19 +44,22 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.playback.composable.VideoView +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveFlag import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.ScheduledFlag @@ -92,7 +94,7 @@ fun RenderLiveActivityEventPreview() { runBlocking { withContext(Dispatchers.IO) { - LocalCache.justConsume(event, null) + LocalCache.justConsume(event, null, false) baseNote = LocalCache.getOrCreateNote("19406ad34ce3c653d62eb73c1816ac27dcf473c2ccdccf5af7d90d2633c62561") } } @@ -130,7 +132,7 @@ fun RenderLiveActivityEventInner( ) { val noteEvent = baseNote.event as? LiveActivitiesEvent ?: return - val eventUpdates by baseNote.live().metadata.observeAsState() + val eventUpdates by observeNote(baseNote, accountViewModel) val media = remember(eventUpdates) { noteEvent.streaming() } val cover = remember(eventUpdates) { noteEvent.image() } @@ -161,19 +163,22 @@ fun RenderLiveActivityEventInner( CrossfadeIfEnabled(targetState = status, label = "RenderLiveActivityEventInner", accountViewModel = accountViewModel) { when (it) { - StatusTag.STATUS.LIVE.code -> { + StatusTag.STATUS.LIVE -> { media?.let { CrossfadeCheckIfVideoIsOnline(it, accountViewModel) { LiveFlag() } } } - StatusTag.STATUS.PLANNED.code -> { + StatusTag.STATUS.PLANNED -> { ScheduledFlag(starts) } + + StatusTag.STATUS.ENDED -> {} + null -> {} } } } media?.let { media -> - if (status == StatusTag.STATUS.LIVE.code) { + if (status == StatusTag.STATUS.LIVE) { CheckIfVideoIsOnline(media, accountViewModel) { isOnline -> if (isOnline) { Row( @@ -208,7 +213,7 @@ fun RenderLiveActivityEventInner( } } } else { - if (status == StatusTag.STATUS.ENDED.code || (status == StatusTag.STATUS.PLANNED.code && (starts ?: 0) < TimeUtils.eightHoursAgo())) { + if (status == StatusTag.STATUS.ENDED || (status == StatusTag.STATUS.PLANNED && (starts ?: 0) < TimeUtils.eightHoursAgo())) { Box( contentAlignment = Alignment.Center, modifier = @@ -220,9 +225,22 @@ fun RenderLiveActivityEventInner( Row( verticalAlignment = Alignment.CenterVertically, ) { - AsyncImage(model = it, contentDescription = null, modifier = MaterialTheme.colorScheme.imageModifier) + MyAsyncImage( + imageUrl = it, + contentDescription = + stringRes( + R.string.preview_card_image_for, + it, + ), + contentScale = ContentScale.FillWidth, + mainImageModifier = Modifier.fillMaxWidth(), + loadedImageModifier = MaterialTheme.colorScheme.imageModifier, + accountViewModel = accountViewModel, + onLoadingBackground = { DefaultImageHeaderBackground(baseNote, accountViewModel) }, + onError = { DefaultImageHeader(baseNote, accountViewModel) }, + ) } - } ?: run { DisplayAuthorBanner(baseNote) } + } ?: run { DisplayAuthorBanner(baseNote, accountViewModel, MaterialTheme.colorScheme.imageModifier) } Text( text = stringRes(id = R.string.live_stream_has_ended), @@ -262,7 +280,10 @@ fun RenderLiveActivityEventInner( Spacer(StdHorzSpacer) it.first.role?.let { Text( - text = it.capitalize(Locale.ROOT), + text = + it.replaceFirstChar { + if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() + }, color = MaterialTheme.colorScheme.placeholderText, maxLines = 1, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivityChatMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivityChatMessage.kt index 25e0c0d442..6526e77f4c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivityChatMessage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivityChatMessage.kt @@ -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 @@ -31,9 +31,10 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.GenericLoadable -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.LoadLiveActivityChannel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ChannelHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivitiesChannelHeader import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent @@ -53,21 +54,24 @@ fun RenderLiveActivityChatMessage( val showChannelInfo = remember(noteEvent) { if (noteEvent is LiveActivitiesChatMessageEvent) { - noteEvent.activity()?.toTag() + noteEvent.activityAddress() } else { null } } showChannelInfo?.let { - ChannelHeader( - channelHex = it, - showVideo = false, - sendToChannel = true, - modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), - accountViewModel = accountViewModel, - nav = nav, - ) + LoadLiveActivityChannel(it, accountViewModel) { + LiveActivitiesChannelHeader( + baseChannel = it, + showVideo = false, + showFlag = true, + sendToChannel = true, + modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), + accountViewModel = accountViewModel, + nav = nav, + ) + } Spacer(modifier = StdVertSpacer) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt index d02d4f42da..235cdea0e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt @@ -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,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.note.types -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -30,22 +29,20 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.QuoteBorder -import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer -import com.vitorpamplona.amethyst.ui.theme.subtleBorder +import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent @Composable @@ -60,7 +57,7 @@ fun RenderLongFormContent( } @Composable -private fun LongFormHeader( +fun LongFormHeader( noteEvent: LongTextNoteEvent, note: Note, accountViewModel: AccountViewModel, @@ -72,37 +69,21 @@ private fun LongFormHeader( noteEvent.summary()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null } } - Column( - modifier = - Modifier - .padding(top = Size5dp) - .clip(shape = QuoteBorder) - .border( - 1.dp, - MaterialTheme.colorScheme.subtleBorder, - QuoteBorder, - ), - ) { - val automaticallyShowUrlPreview = - remember { accountViewModel.settings.showImages.value } - - if (automaticallyShowUrlPreview) { - image?.let { - AsyncImage( - model = it, - contentDescription = - stringRes( - R.string.preview_card_image_for, - it, - ), - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth(), - ) - } ?: run { - DefaultImageHeader(note, accountViewModel) - } + Column(MaterialTheme.colorScheme.replyModifier) { + image?.let { + MyAsyncImage( + imageUrl = it, + contentDescription = stringRes(R.string.preview_card_image_for, it), + contentScale = ContentScale.FillWidth, + mainImageModifier = Modifier.fillMaxWidth(), + loadedImageModifier = Modifier, + accountViewModel = accountViewModel, + onLoadingBackground = { DefaultImageHeaderBackground(note, accountViewModel) }, + onError = { DefaultImageHeader(note, accountViewModel) }, + ) + } ?: run { + DefaultImageHeader(note, accountViewModel, Modifier.fillMaxWidth()) } - title?.let { Text( text = it, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MedicalData.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MedicalData.kt index 8d2e8697fa..6ee786b6ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MedicalData.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MedicalData.kt @@ -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 @@ -51,7 +51,7 @@ import com.vitorpamplona.amethyst.model.Resource import com.vitorpamplona.amethyst.model.VisionPrescription import com.vitorpamplona.amethyst.model.findReferenceInDb import com.vitorpamplona.amethyst.model.parseResourceBundleOrNull -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.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer @@ -65,6 +65,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.text.DecimalFormat import java.text.NumberFormat +import java.util.Locale import kotlin.math.abs @Preview @@ -257,7 +258,9 @@ fun RenderEyeGlassesPrescription( } visionPrescription.status?.let { Text( - text = "Status: ${it.capitalize()}", + text = "Status: ${it.replaceFirstChar { + if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString() + }}", modifier = Modifier.padding(4.dp).fillMaxWidth(), ) } @@ -358,7 +361,10 @@ fun RenderEyeGlassesPrescriptionRow(data: LensSpecification) { verticalAlignment = Alignment.CenterVertically, ) { Text( - text = data.eye?.capitalize() ?: "Unknown", + text = + data.eye?.replaceFirstChar { + if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString() + } ?: "Unknown", modifier = Modifier.padding(4.dp).weight(1f), ) VerticalDivider(thickness = DividerThickness) @@ -514,7 +520,10 @@ fun RenderEyeContactsPrescriptionRow(data: LensSpecification) { verticalAlignment = Alignment.CenterVertically, ) { Text( - text = data.eye?.capitalize() ?: "Unknown", + text = + data.eye?.replaceFirstChar { + if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString() + } ?: "Unknown", modifier = Modifier.padding(4.dp).weight(1f), ) VerticalDivider(thickness = DividerThickness) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt index d1c8577717..66c9bb98ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt @@ -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 @@ -29,7 +29,7 @@ import androidx.compose.ui.graphics.Color import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags @@ -73,6 +73,6 @@ fun RenderNIP90ContentDiscoveryResponse( } if (noteEvent.hasHashtags()) { - DisplayUncitedHashtags(noteEvent, noteEvent.content, callbackUri, nav) + DisplayUncitedHashtags(noteEvent, noteEvent.content, callbackUri, accountViewModel, nav) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90Status.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90Status.kt index eae45a2293..f4ce7b4152 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90Status.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90Status.kt @@ -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.ui.note.types import androidx.compose.material3.Text import androidx.compose.runtime.Composable import com.vitorpamplona.amethyst.model.Note -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.nip90Dvms.NIP90StatusEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt index 48f819e1cc..45d867a2d0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt @@ -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,13 +49,13 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.ShowMoreButton -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.note.getGradient import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt index a2ff34d8e4..67ad354aa7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt @@ -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 @@ -41,7 +41,7 @@ import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableContentView -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.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt index 7f4cf671bc..39eafb0c38 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt @@ -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 @@ -48,7 +48,7 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.ShowMoreButton import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.PinIcon import com.vitorpamplona.amethyst.ui.note.getGradient import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt index 090ce4d945..089b67877b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt @@ -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 @@ -32,10 +32,11 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.PollNote import com.vitorpamplona.amethyst.ui.note.ReplyNoteComposition import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags @@ -76,7 +77,7 @@ fun RenderPoll( val replyingTo = noteEvent.replyingToAddressOrEvent() if (replyingTo != null) { val newNote = accountViewModel.getNoteIfExists(replyingTo) - if (newNote != null && newNote.channelHex() == null && newNote.event?.kind != CommunityDefinitionEvent.KIND) { + if (newNote != null && LocalCache.getAnyChannel(newNote) == null && newNote.event?.kind != CommunityDefinitionEvent.KIND) { newNote } else { note.replyTo?.lastOrNull { it.event?.kind != CommunityDefinitionEvent.KIND } @@ -129,7 +130,7 @@ fun RenderPoll( } if (noteEvent.hasHashtags()) { - DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, nav) + DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, accountViewModel, nav) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt index 3f8157de37..a17db97998 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt @@ -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 @@ -38,8 +38,8 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -77,7 +77,7 @@ fun RenderPrivateMessage( userRoom?.let { if (it.users.size > 1 || (it.users.size == 1 && note.author == accountViewModel.account.userProfile())) { ChatroomHeader(it, MaterialTheme.colorScheme.replyModifier.padding(10.dp), accountViewModel) { - routeFor(note, accountViewModel.userProfile())?.let { + routeFor(note, accountViewModel.account)?.let { nav.nav(it) } } @@ -124,7 +124,7 @@ fun RenderPrivateMessage( } if (noteEvent.hasHashtags()) { - DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, nav) + DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, accountViewModel, nav) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PublicMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PublicMessage.kt new file mode 100644 index 0000000000..69dd2052cd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PublicMessage.kt @@ -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.ui.note.types + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags +import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists + +@Composable +fun RenderPublicMessage( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = note.event as? PublicMessageEvent ?: return + + if (makeItShort && accountViewModel.isLoggedUser(note.author)) { + Text( + text = noteEvent.content, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } else { + val callbackUri = remember(note) { note.toNostrUri() } + + SensitivityWarning( + note = note, + accountViewModel = accountViewModel, + ) { + TranslatableRichTextViewer( + content = noteEvent.content, + canPreview = canPreview && !makeItShort, + quotesLeft = quotesLeft, + modifier = Modifier.fillMaxWidth(), + tags = remember(noteEvent) { noteEvent.tags.toImmutableListOfLists() }, + backgroundColor = backgroundColor, + id = note.idHex, + callbackUri = callbackUri, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (noteEvent.hasHashtags()) { + DisplayUncitedHashtags(noteEvent, noteEvent.content, callbackUri, accountViewModel, nav) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt index e0cee39b29..eb2a23085a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt @@ -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,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt index 1e52c2f01b..f38e18a308 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt @@ -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 @@ -30,37 +30,38 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.nip51Lists.relayLists.RelayListCard +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserRelayIntoList import com.vitorpamplona.amethyst.ui.components.ShowMoreButton -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.AddRelayButton import com.vitorpamplona.amethyst.ui.note.RemoveRelayButton import com.vitorpamplona.amethyst.ui.note.getGradient import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache import com.vitorpamplona.quartz.nip01Core.core.firstTagValueFor +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent -import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.RelaySetEvent +import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @Composable @@ -75,7 +76,9 @@ fun DisplayRelaySet( val relays by remember(noteEvent) { mutableStateOf( - noteEvent.relays().map { RelayBriefInfoCache.RelayBriefInfo(it) }.toImmutableList(), + RelayListCard( + noteEvent.relays().toImmutableList(), + ), ) } @@ -106,14 +109,18 @@ fun DisplayNIP65RelayList( val writeRelays by remember(baseNote) { mutableStateOf( - noteEvent.writeRelays().map { RelayBriefInfoCache.RelayBriefInfo(it) }.toImmutableList(), + RelayListCard( + noteEvent.writeRelaysNorm() ?: emptyList(), + ), ) } val readRelays by remember(baseNote) { mutableStateOf( - noteEvent.readRelays()?.map { RelayBriefInfoCache.RelayBriefInfo(it) }?.toImmutableList() ?: persistentListOf(), + RelayListCard( + noteEvent.readRelaysNorm() ?: emptyList(), + ), ) } @@ -148,7 +155,9 @@ fun DisplayDMRelayList( val relays by remember(baseNote) { mutableStateOf( - noteEvent.relays().map { RelayBriefInfoCache.RelayBriefInfo(it) }.toImmutableList(), + RelayListCard( + noteEvent.relays(), + ), ) } @@ -169,14 +178,9 @@ fun DisplaySearchRelayList( accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent = baseNote.event as? SearchRelayListEvent ?: return - - val relays by - remember(baseNote) { - mutableStateOf( - noteEvent.relays().map { RelayBriefInfoCache.RelayBriefInfo(it) }.toImmutableList(), - ) - } + val relays by accountViewModel.account.searchRelayListDecryptionCache.observeDecryptedRelayList(baseNote).collectAsStateWithLifecycle( + accountViewModel.account.searchRelayListDecryptionCache.fastStartValueForRelayList(baseNote), + ) DisplayRelaySet( relays, @@ -188,9 +192,114 @@ fun DisplaySearchRelayList( ) } +@Composable +fun DisplayBlockedRelayList( + baseNote: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val relays by accountViewModel.account.blockedRelayListDecryptionCache.observeDecryptedRelayList(baseNote).collectAsStateWithLifecycle( + accountViewModel.account.blockedRelayListDecryptionCache.fastStartValueForRelayList(baseNote), + ) + + DisplayRelaySet( + relays, + stringRes(id = R.string.blocked_relays_title), + null, + backgroundColor, + accountViewModel, + nav, + ) +} + +@Composable +fun DisplayTrustedRelayList( + baseNote: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val relays by accountViewModel.account.trustedRelayListDecryptionCache.observeDecryptedRelayList(baseNote).collectAsStateWithLifecycle( + accountViewModel.account.trustedRelayListDecryptionCache.fastStartValueForRelayList(baseNote), + ) + + DisplayRelaySet( + relays, + stringRes(id = R.string.trusted_relays_title), + null, + backgroundColor, + accountViewModel, + nav, + ) +} + +@Composable +fun DisplayProxyRelayList( + baseNote: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val relays by accountViewModel.account.proxyRelayListDecryptionCache.observeDecryptedRelayList(baseNote).collectAsStateWithLifecycle( + accountViewModel.account.proxyRelayListDecryptionCache.fastStartValueForRelayList(baseNote), + ) + + DisplayRelaySet( + relays, + stringRes(id = R.string.proxy_relays_title), + null, + backgroundColor, + accountViewModel, + nav, + ) +} + +@Composable +fun DisplayIndexerRelayList( + baseNote: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val relays by accountViewModel.account.indexerRelayListDecryptionCache.observeDecryptedRelayList(baseNote).collectAsStateWithLifecycle( + accountViewModel.account.indexerRelayListDecryptionCache.fastStartValueForRelayList(baseNote), + ) + + DisplayRelaySet( + relays, + stringRes(id = R.string.indexer_relays_title), + null, + backgroundColor, + accountViewModel, + nav, + ) +} + +@Composable +fun DisplayBroadcastRelayList( + baseNote: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val relays by accountViewModel.account.broadcastRelayListDecryptionCache.observeDecryptedRelayList(baseNote).collectAsStateWithLifecycle( + accountViewModel.account.broadcastRelayListDecryptionCache.fastStartValueForRelayList(baseNote), + ) + + DisplayRelaySet( + relays, + stringRes(id = R.string.broadcast_relays_title), + null, + backgroundColor, + accountViewModel, + nav, + ) +} + @Composable fun DisplayRelaySet( - relays: ImmutableList, + relay: RelayListCard, relayListName: String, relayDescription: String?, backgroundColor: MutableState, @@ -201,9 +310,9 @@ fun DisplayRelaySet( val toMembersShow = if (expanded) { - relays + relay.relays } else { - relays.take(3) + relay.relays.take(3) } Text( @@ -240,7 +349,7 @@ fun DisplayRelaySet( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = relay.displayUrl, + text = relay.displayUrl(), fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -251,13 +360,13 @@ fun DisplayRelaySet( ) Column(modifier = Modifier.padding(start = 10.dp)) { - RelayOptionsAction(relay.url, accountViewModel, nav) + RelayOptionsAction(relay, accountViewModel, nav) } } } } - if (relays.size > 3 && !expanded) { + if (relay.relays.size > 3 && !expanded) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, @@ -275,33 +384,21 @@ fun DisplayRelaySet( @Composable private fun RelayOptionsAction( - relay: String, + relay: NormalizedRelayUrl, accountViewModel: AccountViewModel, nav: INav, ) { - val userStateRelayInfo by accountViewModel.account - .userProfile() - .live() - .relayInfo - .observeAsState() - val isCurrentlyOnTheUsersList by - remember(userStateRelayInfo) { - derivedStateOf { - userStateRelayInfo - ?.user - ?.latestContactList - ?.relays() - ?.none { it.key == relay } == true - } - } + val isCurrentlyOnTheUsersList by observeUserRelayIntoList(accountViewModel.userProfile(), relay, accountViewModel) + val clipboardManager = LocalClipboardManager.current if (isCurrentlyOnTheUsersList) { AddRelayButton { - nav.nav(Route.EditRelays(relay)) + clipboardManager.setText(AnnotatedString(relay.url)) + nav.nav(Route.EditRelays) } } else { RemoveRelayButton { - nav.nav(Route.EditRelays(relay)) + nav.nav(Route.EditRelays) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderPostApproval.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderPostApproval.kt index 8b0b10280c..3a3f336156 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderPostApproval.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderPostApproval.kt @@ -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 @@ -35,7 +35,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt index 3f400a768f..bbc44aab40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt @@ -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 @@ -29,7 +29,7 @@ import androidx.compose.ui.graphics.Color import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt index 4346317530..49867e15e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt @@ -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 @@ -33,18 +33,18 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent import com.vitorpamplona.amethyst.ui.note.ReplyNoteComposition import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags import com.vitorpamplona.quartz.nip01Core.tags.people.hasAnyTaggedUser import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList @@ -66,7 +66,7 @@ fun RenderTextEvent( accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent = note.event as? Event ?: return + val noteEvent = note.event ?: return val showReply by remember(note) { @@ -82,7 +82,7 @@ fun RenderTextEvent( val replyingTo = noteEvent.replyingToAddressOrEvent() if (replyingTo != null) { val newNote = accountViewModel.getNoteIfExists(replyingTo) - if (newNote != null && newNote.channelHex() == null && newNote.event?.kind != CommunityDefinitionEvent.KIND) { + if (newNote != null && LocalCache.getAnyChannel(newNote) == null && newNote.event?.kind != CommunityDefinitionEvent.KIND) { newNote } else { note.replyTo?.lastOrNull { it.event?.kind != CommunityDefinitionEvent.KIND } @@ -163,7 +163,7 @@ fun RenderTextEvent( } if (noteEvent.hasHashtags()) { - DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, nav) + DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, accountViewModel, nav) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt index cef42b915d..fa80c2a343 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt @@ -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 @@ -35,7 +35,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -45,12 +45,13 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.ui.actions.EditPostView import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.NoteBody import com.vitorpamplona.amethyst.ui.note.observeEdits import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -130,7 +131,7 @@ fun RenderTextModificationEvent( noteEvent.editedNote()?.let { LoadNote(baseNoteHex = it.eventId, accountViewModel = accountViewModel) { baseNote -> baseNote?.let { - val noteState by baseNote.live().metadata.observeAsState() + val noteState by observeNote(baseNote, accountViewModel) val editStateOriginalNote = observeEdits(baseNote = baseNote, accountViewModel = accountViewModel) @@ -176,7 +177,7 @@ fun RenderTextModificationEvent( .clickable { routeFor( baseNote, - accountViewModel.userProfile(), + accountViewModel.account, )?.let { nav.nav(it) } }, ) { @@ -232,7 +233,7 @@ class EditState { private var modificationToShowIndex: Int = -1 val modificationToShow: MutableState = mutableStateOf(null) - val showingVersion: MutableState = mutableStateOf(0) + val showingVersion: MutableState = mutableIntStateOf(0) fun hasModificationsToShow(): Boolean = modificationsList.isNotEmpty() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Torrent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Torrent.kt index e8a2ee8776..81a196ec63 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Torrent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Torrent.kt @@ -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,7 +21,6 @@ package com.vitorpamplona.amethyst.ui.note.types import android.content.Intent -import android.net.Uri import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -48,14 +47,14 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat +import androidx.core.net.toUri import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.countToHumanReadableBytes import com.vitorpamplona.amethyst.ui.components.ShowMoreButton -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon import com.vitorpamplona.amethyst.ui.note.getGradient import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -127,7 +126,7 @@ fun TorrentPreview() { sig = "40e1ccfdc38a32e6c164bb66e50df0cd3769e0431137a07709534a72b462dfcbc40106560d0dd66841fef4cbb7aece7db64e83a0fbe414759d4d9a799e522c57", ) - LocalCache.justConsume(torrent, null) + LocalCache.justConsume(torrent, null, false) LocalCache.getOrCreateNote("e1ab66dd66e6ac4f32119deacf80d7c50787705d343d3bb896c099cf93821757") } @@ -213,17 +212,20 @@ fun DisplayFileList( val context = LocalContext.current - IconButton(onClick = { - try { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link())) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + IconButton( + onClick = { + try { + val intent = Intent(Intent.ACTION_VIEW, link().toUri()) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - ContextCompat.startActivity(context, intent, null) - } catch (e: Exception) { - if (e is CancellationException) throw e - accountViewModel.toastManager.toast(R.string.torrent_failure, R.string.torrent_no_apps) - } - }, Modifier.size(Size30dp)) { + context.startActivity(intent) + } catch (e: Exception) { + if (e is CancellationException) throw e + accountViewModel.toastManager.toast(R.string.torrent_failure, R.string.torrent_no_apps) + } + }, + Modifier.size(Size30dp), + ) { DownloadForOfflineIcon(Size20dp, MaterialTheme.colorScheme.onBackground) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt index 3fd0f6a86d..d9d91d3fb6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt @@ -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 @@ -35,7 +35,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.State import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -48,11 +47,12 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.countToHumanReadableBytes +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.LoadNote -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -129,8 +129,8 @@ fun TorrentCommentPreview() { sig = "014391c310b1eebb807da4c9b11563126f2b795c9372a9432cae4dd2c0695b88584bb1c68814554c9b1a47626e3d60983e3653c29d0fdbc3a474277c140b95c3", ) - LocalCache.justConsume(torrent, null) - LocalCache.justConsume(comment, null) + LocalCache.justConsume(torrent, null, false) + LocalCache.justConsume(comment, null, false) LocalCache.getOrCreateNote("040aba32010b5adf7cb917054e894e86c8ea7a2bcee448b2266c493f3140e9a0") } } @@ -223,19 +223,29 @@ fun ShortTorrentHeader( accountViewModel: AccountViewModel, nav: INav, ) { - val channelState by baseNote.live().metadata.observeAsState() - val note = channelState?.note ?: return - val noteEvent = note.event as? TorrentEvent ?: return + val noteEvent by observeNoteEvent(baseNote, accountViewModel) + ShortTorrentHeader( + title = noteEvent?.title() ?: TorrentEvent.ALT_DESCRIPTION, + size = noteEvent?.totalSizeBytes()?.let { countToHumanReadableBytes(it) } ?: "--", + modifier.clickable { routeFor(baseNote, accountViewModel.account)?.let { nav.nav(it) } }, + accountViewModel, + nav, + ) +} + +@Composable +fun ShortTorrentHeader( + title: String, + size: String, + modifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + nav: INav, +) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = - modifier.clickable { - routeFor(baseNote, accountViewModel.userProfile())?.let { nav.nav(it) } - }, + modifier = modifier, ) { - Icons.Outlined.FileOpen - Icon( imageVector = Icons.Outlined.FileOpen, contentDescription = stringRes(id = R.string.torrent_file), @@ -243,17 +253,14 @@ fun ShortTorrentHeader( ) Text( - text = remember(channelState) { noteEvent.title() ?: TorrentEvent.ALT_DESCRIPTION }, + text = title, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .padding(start = 10.dp) - .weight(1f), + modifier = Modifier.padding(start = 10.dp).weight(1f), ) Text( - text = remember(channelState) { countToHumanReadableBytes(noteEvent.totalSizeBytes()) }, + text = size, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(end = 5.dp), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt index 882053d595..0ae502b832 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt @@ -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 @@ -41,18 +41,19 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableContentView -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -131,15 +132,19 @@ fun VideoDisplay( modifier = Modifier.clickable { runCatching { uri.openUri(imeta.url) } }, ) { image?.let { - AsyncImage( - model = it, + MyAsyncImage( + imageUrl = it, contentDescription = stringRes( R.string.preview_card_image_for, it, ), contentScale = ContentScale.FillWidth, - modifier = MaterialTheme.colorScheme.imageModifier, + mainImageModifier = Modifier, + loadedImageModifier = MaterialTheme.colorScheme.imageModifier, + accountViewModel = accountViewModel, + onLoadingBackground = { DefaultImageHeaderBackground(note, accountViewModel) }, + onError = { DefaultImageHeader(note, accountViewModel) }, ) } ?: run { DefaultImageHeader(note, accountViewModel) @@ -186,7 +191,7 @@ fun VideoDisplay( Row( Modifier.fillMaxWidth(), ) { - DisplayUncitedHashtags(event, summary, callbackUri, nav) + DisplayUncitedHashtags(event, summary, callbackUri, accountViewModel, nav) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt index f7556eee37..c9cc553e84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt new file mode 100644 index 0000000000..a0ace2304a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt @@ -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.ui.note.types + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.playback.composable.VideoView +import com.vitorpamplona.amethyst.service.playback.composable.WaveformData +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags +import com.vitorpamplona.quartz.nip14Subject.subject +import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent + +@Composable +fun RenderVoiceTrack( + note: Note, + contentScale: ContentScale, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = note.event as? BaseVoiceEvent ?: return + + VoiceHeader(noteEvent, note, contentScale, accountViewModel, nav) +} + +@Composable +fun VoiceHeader( + noteEvent: BaseVoiceEvent, + note: Note, + contentScale: ContentScale, + accountViewModel: AccountViewModel, + nav: INav, +) { + val waveform = + remember(noteEvent) { + noteEvent + .iMetaTags() + .firstOrNull() + ?.waveform + ?.let { WaveformData(it) } + } + val media = + remember(noteEvent) { + noteEvent.content.ifBlank { null } ?: noteEvent.iMetaTags().firstOrNull()?.url + } + + if (media == null) return + + Column(modifier = Modifier.fillMaxWidth().padding(top = 5.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + VideoView( + videoUri = media, + mimeType = null, + waveform = waveform, + title = noteEvent.subject(), + authorName = note.author?.toBestDisplayName(), + roundedCorner = true, + contentScale = contentScale, + accountViewModel = accountViewModel, + nostrUriCallback = note.toNostrUri(), + ) + } + + val callbackUri = remember(note) { note.toNostrUri() } + + if (noteEvent.hasHashtags()) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + DisplayUncitedHashtags(noteEvent, callbackUri, accountViewModel, nav) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Wiki.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Wiki.kt index 9bde2174e3..2a12a87985 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Wiki.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Wiki.kt @@ -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 @@ -36,16 +36,18 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.imageModifier import com.vitorpamplona.amethyst.ui.theme.subtleBorder import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent @@ -91,15 +93,19 @@ private fun WikiNoteHeader( if (automaticallyShowUrlPreview) { image?.let { - AsyncImage( - model = it, + MyAsyncImage( + imageUrl = it, contentDescription = stringRes( R.string.preview_card_image_for, it, ), contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth(), + mainImageModifier = Modifier, + loadedImageModifier = MaterialTheme.colorScheme.imageModifier, + accountViewModel = accountViewModel, + onLoadingBackground = { DefaultImageHeaderBackground(note, accountViewModel) }, + onError = { DefaultImageHeader(note, accountViewModel) }, ) } ?: run { DefaultImageHeader(note, accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt index 0379b09c78..0261ebd111 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt @@ -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 @@ -46,7 +46,7 @@ fun AccountScreen( ) { val accountState by accountStateViewModel.accountContent.collectAsStateWithLifecycle() - Log.d("ManageRelayServices", "AccountScreen $accountState $accountStateViewModel") + Log.d("ActivityLifecycle", "AccountScreen $accountState $accountStateViewModel") Crossfade( targetState = accountState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt index 1fb045e656..7976be8ef3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt @@ -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,13 +22,12 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModelStore import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import com.vitorpamplona.amethyst.model.AccountSettings -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.Route sealed class AccountState { object Loading : AccountState() @@ -55,11 +54,13 @@ fun SetAccountCentricViewModelStore( content() } - DisposableEffect(key1 = state) { - onDispose { - state.currentViewModelStore.viewModelStore.clear() - } - } + // moved this clearing activity to the viewmodel account + // because the new composable might run before the onDispose. + // DisposableEffect(key1 = state) { + // onDispose { + // state.currentViewModelStore.viewModelStore.clear() + // } + // } } class AccountCentricViewModelStore : ViewModelStoreOwner { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index 4b2f623ff0..cbaecf3f0e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt @@ -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 @@ -31,19 +31,19 @@ import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.DefaultChannels import com.vitorpamplona.amethyst.model.DefaultDMRelayList import com.vitorpamplona.amethyst.model.DefaultNIP65List +import com.vitorpamplona.amethyst.model.DefaultNIP65RelaySet import com.vitorpamplona.amethyst.model.DefaultSearchRelayList +import com.vitorpamplona.amethyst.model.preferences.AccountPreferenceStores.Companion.torSettings import com.vitorpamplona.amethyst.service.Nip05NostrAddressVerifier -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.tor.TorSettings import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow -import com.vitorpamplona.ammolite.relays.Constants 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.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip02FollowList.ReadWrite import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06 import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent @@ -52,16 +52,19 @@ import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay import com.vitorpamplona.quartz.nip19Bech32.entities.NSec import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49 import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.utils.Hex import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.GlobalScope @@ -84,26 +87,30 @@ class AccountStateViewModel : ViewModel() { private var collectorJob: Job? = null - fun tryLoginExistingAccountAsync() { + fun loginWithDefaultAccountIfLoggedOff() { // pulls account from storage. if (_accountContent.value !is AccountState.LoggedIn) { viewModelScope.launch { - tryLoginExistingAccount() + loginWithDefaultAccount() } } } - private suspend fun tryLoginExistingAccount(route: Route? = null) = - withContext(Dispatchers.IO) { - LocalPreferences.loadCurrentAccountFromEncryptedStorage() - }?.let { startUI(it, route) } ?: run { requestLoginUI() } + private suspend fun loginWithDefaultAccount(route: Route? = null) { + val accountSettings = + withContext(Dispatchers.IO) { + LocalPreferences.loadCurrentAccountFromEncryptedStorage() + } - private suspend fun requestLoginUI() { - _accountContent.update { AccountState.LoggedOff } - - viewModelScope.launch(Dispatchers.IO) { Amethyst.instance.serviceManager.pauseAndLogOff() } + if (accountSettings != null) { + startUI(accountSettings, route) + } else { + requestLoginUI() + } } + private suspend fun requestLoginUI() = _accountContent.update { AccountState.LoggedOff } + suspend fun loginAndStartUI( key: String, torSettings: TorSettings, @@ -117,7 +124,7 @@ class AccountStateViewModel : ViewModel() { is NSec -> null is NPub -> parsed.hex.hexToByteArray() is NProfile -> parsed.hex.hexToByteArray() - is com.vitorpamplona.quartz.nip19Bech32.entities.Note -> null + is NNote -> null is NEvent -> null is NEmbed -> null is NRelay -> null @@ -168,7 +175,7 @@ class AccountStateViewModel : ViewModel() { ) } - LocalPreferences.updatePrefsForLogin(account) + LocalPreferences.setDefaultAccount(account) startUI(account) } @@ -234,7 +241,7 @@ class AccountStateViewModel : ViewModel() { } else if (EMAIL_PATTERN.matcher(key).matches()) { Nip05NostrAddressVerifier().verifyNip05( key, - okttpClient = { Amethyst.instance.okHttpClients.getHttpClient(false) }, + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(false) }, onSuccess = { publicKey -> loginSync(Hex.decode(publicKey).toNpub(), torSettings, transientAccount, loginWithExternalSigner, packageName, onError) }, @@ -270,6 +277,9 @@ class AccountStateViewModel : ViewModel() { onError: (String) -> Unit, ) { try { + if (_accountContent.value is AccountState.LoggedIn) { + prepareLogoutOrSwitch() + } loginAndStartUI(key, torSettings, transientAccount, loginWithExternalSigner, packageName) } catch (e: Exception) { if (e is CancellationException) throw e @@ -283,55 +293,65 @@ class AccountStateViewModel : ViewModel() { name: String? = null, ) { viewModelScope.launch(Dispatchers.IO) { + if (_accountContent.value is AccountState.LoggedIn) { + prepareLogoutOrSwitch() + } + _accountContent.update { AccountState.Loading } - val keyPair = KeyPair() - val tempSigner = NostrSignerSync(keyPair) + val accountSettings = createNewAccount(torSettings, name) - val accountSettings = - AccountSettings( - keyPair = keyPair, - transientAccount = false, - backupUserMetadata = tempSigner.sign(MetadataEvent.newUser(name)), - backupContactList = - ContactListEvent.createFromScratch( - followUsers = listOf(ContactTag(keyPair.pubKey.toHexKey(), null, null)), - followEvents = DefaultChannels.toList(), - relayUse = - Constants.defaultRelays.associate { - it.url to ReadWrite(it.read, it.write) - }, - signer = tempSigner, - ), - backupNIP65RelayList = AdvertisedRelayListEvent.create(DefaultNIP65List, tempSigner), - backupDMRelayList = ChatMessageRelayListEvent.create(DefaultDMRelayList, tempSigner), - backupSearchRelayList = SearchRelayListEvent.create(DefaultSearchRelayList, tempSigner), - torSettings = TorSettingsFlow.build(torSettings), - ) - - // saves to local preferences - LocalPreferences.updatePrefsForLogin(accountSettings) + LocalPreferences.setDefaultAccount(accountSettings) startUI(accountSettings) + @OptIn(DelicateCoroutinesApi::class) GlobalScope.launch(Dispatchers.IO) { delay(2000) // waits for the new user to connect to the new relays. - accountSettings.backupUserMetadata?.let { Amethyst.instance.client.send(it) } - accountSettings.backupContactList?.let { Amethyst.instance.client.send(it) } - accountSettings.backupNIP65RelayList?.let { Amethyst.instance.client.send(it) } - accountSettings.backupDMRelayList?.let { Amethyst.instance.client.send(it) } - accountSettings.backupSearchRelayList?.let { Amethyst.instance.client.send(it) } + + val toPost = accountSettings.backupNIP65RelayList?.writeRelaysNorm()?.toSet() ?: DefaultNIP65RelaySet + + accountSettings.backupUserMetadata?.let { Amethyst.instance.client.send(it, toPost) } + accountSettings.backupContactList?.let { Amethyst.instance.client.send(it, toPost) } + accountSettings.backupNIP65RelayList?.let { Amethyst.instance.client.send(it, toPost) } + accountSettings.backupDMRelayList?.let { Amethyst.instance.client.send(it, toPost) } + accountSettings.backupSearchRelayList?.let { Amethyst.instance.client.send(it, toPost) } } } } + fun createNewAccount( + torSettings: TorSettings, + name: String? = null, + ): AccountSettings { + val keyPair = KeyPair() + val tempSigner = NostrSignerSync(keyPair) + + return AccountSettings( + keyPair = keyPair, + transientAccount = false, + backupUserMetadata = tempSigner.sign(MetadataEvent.newUser(name)), + backupContactList = + ContactListEvent.createFromScratch( + followUsers = listOf(ContactTag(keyPair.pubKey.toHexKey(), null, null)), + relayUse = emptyMap(), + signer = tempSigner, + ), + backupNIP65RelayList = AdvertisedRelayListEvent.create(DefaultNIP65List, tempSigner), + backupDMRelayList = ChatMessageRelayListEvent.create(DefaultDMRelayList, tempSigner), + backupSearchRelayList = SearchRelayListEvent.create(DefaultSearchRelayList.toList(), tempSigner), + backupChannelList = ChannelListEvent.create(emptyList(), DefaultChannels, tempSigner), + torSettings = TorSettingsFlow.build(torSettings), + ) + } + fun switchUser(accountInfo: AccountInfo) { viewModelScope.launch(Dispatchers.IO) { switchUserSync(accountInfo) } } - suspend fun switchUserSync( + suspend fun checkAndSwitchUserSync( npub: String, route: Route, ): Boolean { @@ -345,34 +365,33 @@ class AccountStateViewModel : ViewModel() { return false } - suspend fun switchUserSync( + private suspend fun switchUserSync( accountInfo: AccountInfo, route: Route? = null, ) { prepareLogoutOrSwitch() LocalPreferences.switchToAccount(accountInfo) - tryLoginExistingAccount(route) + loginWithDefaultAccount(route) } - fun currentAccount() = + fun currentAccountNPub() = when (val state = _accountContent.value) { is AccountState.LoggedIn -> state.accountSettings.keyPair.pubKey .toNpub() - else -> null } fun logOff(accountInfo: AccountInfo) { viewModelScope.launch(Dispatchers.IO) { - if (accountInfo.npub == currentAccount()) { + if (accountInfo.npub == currentAccountNPub()) { // log off and relogin with the 0 account prepareLogoutOrSwitch() - LocalPreferences.updatePrefsForLogout(accountInfo) - tryLoginExistingAccount() + LocalPreferences.deleteAccount(accountInfo) + loginWithDefaultAccount() } else { - // delete without login off - LocalPreferences.updatePrefsForLogout(accountInfo) + // delete without switching logins + LocalPreferences.deleteAccount(accountInfo) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt index d9bd6bac7e..beb4807fdb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt @@ -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 @@ -37,7 +37,7 @@ import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.ui.feeds.rememberForeverLazyGridState import com.vitorpamplona.amethyst.ui.feeds.rememberForeverLazyListState -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt index b5f2626a37..a01472009c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt @@ -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,27 +21,11 @@ package com.vitorpamplona.amethyst.ui.screen import android.util.Log -import androidx.compose.foundation.interaction.DragInteraction -import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.ThreadLevelCalculator -import com.vitorpamplona.amethyst.ui.dal.BookmarkPrivateFeedFilter -import com.vitorpamplona.amethyst.ui.dal.BookmarkPublicFeedFilter -import com.vitorpamplona.amethyst.ui.dal.ChannelFeedFilter -import com.vitorpamplona.amethyst.ui.dal.ChatroomFeedFilter -import com.vitorpamplona.amethyst.ui.dal.CommunityFeedFilter -import com.vitorpamplona.amethyst.ui.dal.DraftEventsFeedFilter import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.dal.FollowSetFeedFilter import com.vitorpamplona.amethyst.ui.dal.GeoHashFeedFilter @@ -49,23 +33,10 @@ import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter import com.vitorpamplona.amethyst.ui.dal.NIP90ContentDiscoveryResponseFilter import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter import com.vitorpamplona.amethyst.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.ui.feeds.FeedState import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.NostrListFeedViewModel import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch class NostrChannelFeedViewModel( @@ -264,21 +235,25 @@ abstract class FeedViewModel( override fun invalidateData(ignoreIfDoing: Boolean) = feedState.invalidateData(ignoreIfDoing) - private var collectorJob: Job? = null - init { Log.d("Init", "Starting new Model: ${this.javaClass.simpleName}") - collectorJob = - viewModelScope.launch(Dispatchers.IO) { - LocalCache.live.newEventBundles.collect { newNotes -> - feedState.updateFeedWith(newNotes) - } + viewModelScope.launch(Dispatchers.Default) { + LocalCache.live.newEventBundles.collect { newNotes -> + Log.d("Rendering Metrics", "Update feeds: ${this@FeedViewModel.javaClass.simpleName} with ${newNotes.size}") + feedState.updateFeedWith(newNotes) } + } + + viewModelScope.launch(Dispatchers.Default) { + LocalCache.live.deletedEventBundles.collect { newNotes -> + Log.d("Rendering Metrics", "Delete from feeds: ${this@FeedViewModel.javaClass.simpleName} with ${newNotes.size}") + feedState.deleteFromFeed(newNotes) + } + } } override fun onCleared() { Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") - collectorJob?.cancel() super.onCleared() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FollowListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FollowListState.kt index a7deab25f2..a563316d3c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FollowListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FollowListState.kt @@ -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,15 +25,15 @@ import android.util.Log import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.ALL_FOLLOWS import com.vitorpamplona.amethyst.model.AROUND_ME import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS -import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent @@ -45,9 +45,10 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent @@ -74,7 +75,7 @@ class FollowListState( ) { val kind3Follow = PeopleListOutBoxFeedDefinition( - code = KIND3_FOLLOWS, + code = ALL_FOLLOWS, name = ResourceName(R.string.follow_list_kind3follows), type = CodeNameType.HARDCODED, kinds = DEFAULT_FEED_KINDS, @@ -87,7 +88,6 @@ class FollowListState( name = ResourceName(R.string.follow_list_global), type = CodeNameType.HARDCODED, kinds = DEFAULT_FEED_KINDS, - relays = account.activeGlobalRelays().toList(), ) val aroundMe = @@ -135,6 +135,7 @@ class FollowListState( ( ( noteEvent is PeopleListEvent || + noteEvent is FollowListEvent || noteEvent is MuteListEvent || noteEvent is ContactListEvent ) || @@ -159,20 +160,19 @@ class FollowListState( @OptIn(ExperimentalCoroutinesApi::class) val liveKind3FollowsFlow: Flow> = - account.liveKind3Follows.transformLatest { + account.kind3FollowList.flow.transformLatest { checkNotInMainThread() val communities = - it.addresses.mapNotNull { + it.communities.mapNotNull { LocalCache.checkGetOrCreateAddressableNote(it)?.let { communityNote -> TagFeedDefinition( "Community/${communityNote.idHex}", CommunityName(communityNote), CodeNameType.ROUTE, - route = Route.Community(communityNote.idHex), + route = Route.Community(communityNote.address.kind, communityNote.address.pubKeyHex, communityNote.address.dTag), kinds = DEFAULT_COMMUNITY_FEEDS, aTags = listOf(communityNote.idHex), - relays = account.activeGlobalRelays().toList(), ) } } @@ -186,7 +186,6 @@ class FollowListState( route = Route.Hashtag(it), kinds = DEFAULT_FEED_KINDS, tTags = listOf(it), - relays = account.activeGlobalRelays().toList(), ) } @@ -199,7 +198,6 @@ class FollowListState( route = Route.Geohash(it), kinds = DEFAULT_FEED_KINDS, gTags = listOf(it), - relays = account.activeGlobalRelays().toList(), ) } @@ -239,8 +237,14 @@ class FollowListState( ) } - val kind3GlobalPeopleRoutes = _kind3GlobalPeopleRoutes.flowOn(Dispatchers.Default).stateIn(viewModelScope, SharingStarted.Eagerly, defaultLists) - val kind3GlobalPeople = _kind3GlobalPeople.flowOn(Dispatchers.Default).stateIn(viewModelScope, SharingStarted.Eagerly, defaultLists) + val kind3GlobalPeopleRoutes = + _kind3GlobalPeopleRoutes + .flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.Eagerly, defaultLists) + val kind3GlobalPeople = + _kind3GlobalPeople + .flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.Eagerly, defaultLists) suspend fun initializeSuspend() { checkNotInMainThread() @@ -288,13 +292,22 @@ class ResourceName( class PeopleListName( val note: AddressableNote, ) : Name() { - override fun name() = (note.event as? PeopleListEvent)?.nameOrTitle() ?: note.dTag() ?: "" + override fun name(): String { + val noteEvent = note.event + return if (noteEvent is PeopleListEvent) { + noteEvent.nameOrTitle() ?: note.dTag() + } else if (noteEvent is FollowListEvent) { + noteEvent.title() ?: note.dTag() + } else { + note.dTag() + } + } } class CommunityName( val note: AddressableNote, ) : Name() { - override fun name() = "/n/${(note.dTag() ?: "")}" + override fun name() = "/n/${(note.dTag())}" } @Immutable @@ -311,7 +324,6 @@ class GlobalFeedDefinition( name: Name, type: CodeNameType, val kinds: List, - val relays: List, ) : FeedDefinition(code, name, type, null) @Immutable @@ -321,7 +333,6 @@ class TagFeedDefinition( type: CodeNameType, route: Route?, val kinds: List, - val relays: List, val pTags: List? = null, val eTags: List? = null, val aTags: List? = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesComposables.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesComposables.kt index 769977210a..4fc932da11 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesComposables.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesComposables.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesViewModel.kt index 2a9959ca06..dffe105810 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesViewModel.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedSettingsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedSettingsState.kt index 8dc707f784..cbdd75954c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedSettingsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedSettingsState.kt @@ -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,6 +24,7 @@ import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.window.layout.DisplayFeature @@ -49,7 +50,7 @@ class SharedSettingsState { var gallerySet by mutableStateOf(ProfileGalleryType.CLASSIC) var isOnMobileOrMeteredConnection by mutableStateOf(false) - var currentNetworkId by mutableStateOf(0L) + var currentNetworkId by mutableLongStateOf(0L) var windowSizeClass = mutableStateOf(null) var displayFeatures = mutableStateOf>(emptyList()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt index e54ddce588..a1c9849971 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt index 23240af960..a89651c332 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt @@ -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 @@ -33,7 +33,7 @@ import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt index ad50a9b12b..64d0813cba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt @@ -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,46 +25,21 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.dal.FeedFilter -import com.vitorpamplona.amethyst.ui.dal.HiddenAccountsFeedFilter -import com.vitorpamplona.amethyst.ui.dal.SpammerAccountsFeedFilter import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists import com.vitorpamplona.ammolite.relays.BundledUpdate import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -class NostrHiddenAccountsFeedViewModel( - val account: Account, -) : UserFeedViewModel(HiddenAccountsFeedFilter(account)) { - class Factory( - val account: Account, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrHiddenAccountsFeedViewModel = NostrHiddenAccountsFeedViewModel(account) as NostrHiddenAccountsFeedViewModel - } -} - -class NostrSpammerAccountsFeedViewModel( - val account: Account, -) : UserFeedViewModel(SpammerAccountsFeedFilter(account)) { - class Factory( - val account: Account, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrSpammerAccountsFeedViewModel = NostrSpammerAccountsFeedViewModel(account) as NostrSpammerAccountsFeedViewModel - } -} - @Stable open class UserFeedViewModel( val dataSource: FeedFilter, @@ -123,26 +98,26 @@ open class UserFeedViewModel( } } - var collectorJob: Job? = null - init { Log.d("Init", "${this.javaClass.simpleName}") - collectorJob = - viewModelScope.launch(Dispatchers.IO) { - checkNotInMainThread() - - LocalCache.live.newEventBundles.collect { newNotes -> - checkNotInMainThread() - - invalidateData() - } + viewModelScope.launch(Dispatchers.Default) { + LocalCache.live.newEventBundles.collect { newNotes -> + Log.d("Rendering Metrics", "Update feeds: ${this@UserFeedViewModel.javaClass.simpleName} with ${newNotes.size}") + invalidateData() } + } + + viewModelScope.launch(Dispatchers.Default) { + LocalCache.live.deletedEventBundles.collect { newNotes -> + Log.d("Rendering Metrics", "Delete from feeds: ${this@UserFeedViewModel.javaClass.simpleName} with ${newNotes.size}") + invalidateData() + } + } } override fun onCleared() { Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") bundler.cancel() - collectorJob?.cancel() super.onCleared() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index ebc6128479..9f945f7ad7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -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,25 +23,30 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter -import com.vitorpamplona.amethyst.ui.dal.ChatroomListNewFeedFilter -import com.vitorpamplona.amethyst.ui.dal.DiscoverChatFeedFilter -import com.vitorpamplona.amethyst.ui.dal.DiscoverCommunityFeedFilter -import com.vitorpamplona.amethyst.ui.dal.DiscoverLiveFeedFilter -import com.vitorpamplona.amethyst.ui.dal.DiscoverMarketplaceFeedFilter -import com.vitorpamplona.amethyst.ui.dal.DiscoverNIP89FeedFilter -import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter -import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter -import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter -import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter +import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState import com.vitorpamplona.amethyst.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.screen.FollowListState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListKnownFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListNewFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.DiscoverLongFormFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.DiscoverChatFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.DiscoverFollowSetsFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.DiscoverLiveFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.DiscoverCommunityFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.DiscoverNIP89FeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.DiscoverMarketplaceFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeConversationsFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeLiveFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedContentState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationSummaryState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.VideoFeedFilter class AccountFeedContentStates( val accountViewModel: AccountViewModel, ) { + val homeLive = ChannelFeedContentState(HomeLiveFilter(accountViewModel.account), accountViewModel.viewModelScope) val homeNewThreads = FeedContentState(HomeNewThreadFeedFilter(accountViewModel.account), accountViewModel.viewModelScope) val homeReplies = FeedContentState(HomeConversationsFeedFilter(accountViewModel.account), accountViewModel.viewModelScope) @@ -50,6 +55,8 @@ class AccountFeedContentStates( val videoFeed = FeedContentState(VideoFeedFilter(accountViewModel.account), accountViewModel.viewModelScope) + val discoverFollowSets = FeedContentState(DiscoverFollowSetsFeedFilter(accountViewModel.account), accountViewModel.viewModelScope) + val discoverReads = FeedContentState(DiscoverLongFormFeedFilter(accountViewModel.account), accountViewModel.viewModelScope) val discoverMarketplace = FeedContentState(DiscoverMarketplaceFeedFilter(accountViewModel.account), accountViewModel.viewModelScope) val discoverDVMs = FeedContentState(DiscoverNIP89FeedFilter(accountViewModel.account), accountViewModel.viewModelScope) val discoverLive = FeedContentState(DiscoverLiveFeedFilter(accountViewModel.account), accountViewModel.viewModelScope) @@ -69,6 +76,7 @@ class AccountFeedContentStates( fun updateFeedsWith(newNotes: Set) { checkNotInMainThread() + homeLive.updateFeedWith(newNotes) homeNewThreads.updateFeedWith(newNotes) homeReplies.updateFeedWith(newNotes) @@ -78,6 +86,8 @@ class AccountFeedContentStates( videoFeed.updateFeedWith(newNotes) discoverMarketplace.updateFeedWith(newNotes) + discoverFollowSets.updateFeedWith(newNotes) + discoverReads.updateFeedWith(newNotes) discoverDVMs.updateFeedWith(newNotes) discoverLive.updateFeedWith(newNotes) discoverCommunities.updateFeedWith(newNotes) @@ -99,6 +109,8 @@ class AccountFeedContentStates( videoFeed.destroy() discoverMarketplace.destroy() + discoverFollowSets.destroy() + discoverReads.destroy() discoverDVMs.destroy() discoverLive.destroy() discoverCommunities.destroy() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt index a36fbc5321..e3e361ebf9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt @@ -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 @@ -37,9 +37,9 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalConfiguration -import com.vitorpamplona.amethyst.ui.navigation.AccountSwitchBottomSheet -import com.vitorpamplona.amethyst.ui.navigation.DrawerContent -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.drawer.AccountSwitchBottomSheet +import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerContent +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import kotlinx.coroutines.launch diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 53ed061a39..d2a08444bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn +import android.annotation.SuppressLint import android.content.Context import android.graphics.drawable.Drawable import android.util.Log @@ -27,6 +28,7 @@ import android.util.LruCache import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable +import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -34,11 +36,14 @@ import androidx.lifecycle.viewmodel.compose.viewModel import coil3.asDrawable import coil3.imageLoader import coil3.request.ImageRequest +import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.collectSuccessfulOperations import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache import com.vitorpamplona.amethyst.commons.compose.GenericBaseCacheAsync +import com.vitorpamplona.amethyst.isDebug +import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.AddressableNote @@ -47,23 +52,30 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.UrlCachedPreviewer import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.model.observables.CreatedAtComparator -import com.vitorpamplona.amethyst.service.CashuProcessor -import com.vitorpamplona.amethyst.service.CashuToken import com.vitorpamplona.amethyst.service.Nip05NostrAddressVerifier import com.vitorpamplona.amethyst.service.Nip11CachedRetriever import com.vitorpamplona.amethyst.service.Nip11Retriever import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.ZapPaymentHandler +import com.vitorpamplona.amethyst.service.cashu.CashuToken +import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver -import com.vitorpamplona.amethyst.service.proxyPort.ProxyPortFlow +import com.vitorpamplona.amethyst.service.uploads.CompressorQuality +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadingState import com.vitorpamplona.amethyst.ui.actions.Dao +import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk +import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult import com.vitorpamplona.amethyst.ui.components.UrlPreviewState import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager import com.vitorpamplona.amethyst.ui.feeds.FeedState -import com.vitorpamplona.amethyst.ui.navigation.Route -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus import com.vitorpamplona.amethyst.ui.note.showAmount @@ -74,37 +86,40 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.tor.TorSettings -import com.vitorpamplona.ammolite.relays.BundledInsert +import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent 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.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata -import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser -import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation -import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay import com.vitorpamplona.quartz.nip19Bech32.entities.NSec +import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel import com.vitorpamplona.quartz.nip37Drafts.DraftEvent +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.Response -import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.GeneralListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent @@ -112,11 +127,12 @@ import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.mapNotNullAsync import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf @@ -125,7 +141,6 @@ import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -134,34 +149,32 @@ import kotlinx.coroutines.flow.combineTransform 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.launch import kotlinx.coroutines.withContext import okhttp3.OkHttpClient +import java.util.Locale @Stable class AccountViewModel( accountSettings: AccountSettings, val settings: SharedSettingsState, + val app: Amethyst, ) : ViewModel(), Dao { - val account = Account(accountSettings, accountSettings.createSigner(), viewModelScope) - - val proxyPortLogic = - ProxyPortFlow( - account.settings.torSettings.torType, - account.settings.torSettings.externalSocksPort, - Amethyst.instance.torManager.status, - ).status.stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(30000), - ProxyPortFlow.computePort( - account.settings.torSettings.torType.value, - account.settings.torSettings.externalSocksPort.value, - Amethyst.instance.torManager.status.value, - ), + val account = + Account( + settings = accountSettings, + signer = accountSettings.createSigner(app.contentResolver), + geolocationFlow = app.locationManager.geohashStateFlow, + cache = LocalCache, + client = app.client, + scope = viewModelScope, ) + val newNotesPreProcessor = EventProcessor(account, LocalCache) + var firstRoute: Route? = null // TODO: contact lists are not notes yet @@ -225,7 +238,10 @@ class AccountViewModel( emit(newestItemCreatedAt != null && newestItemCreatedAt > lastRead) } - val notificationHasNewItemsFlow = notificationHasNewItems.flowOn(Dispatchers.Default).stateIn(viewModelScope, SharingStarted.Eagerly, false) + val notificationHasNewItemsFlow = + notificationHasNewItems + .flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.Eagerly, false) @OptIn(ExperimentalCoroutinesApi::class) val messagesHasNewItems = @@ -256,7 +272,10 @@ class AccountViewModel( } } - val messagesHasNewItemsFlow = messagesHasNewItems.flowOn(Dispatchers.Default).stateIn(viewModelScope, SharingStarted.Eagerly, false) + val messagesHasNewItemsFlow = + messagesHasNewItems + .flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.Eagerly, false) @OptIn(ExperimentalCoroutinesApi::class) val homeHasNewItems = @@ -276,7 +295,10 @@ class AccountViewModel( emit(newestItemCreatedAt != null && newestItemCreatedAt > lastRead) } - val homeHasNewItemsFlow = homeHasNewItems.flowOn(Dispatchers.Default).stateIn(viewModelScope, SharingStarted.Eagerly, false) + val homeHasNewItemsFlow = + homeHasNewItems + .flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.Eagerly, false) val hasNewItems = mapOf( @@ -289,13 +311,6 @@ class AccountViewModel( fun userProfile(): User = account.userProfile() - suspend fun reactTo( - note: Note, - reaction: String, - ) { - account.reactTo(note, reaction) - } - fun observeByETag( kind: Int, eTag: HexKey, @@ -310,8 +325,8 @@ class AccountViewModel( note: Note, reaction: String, ) { - viewModelScope.launch(Dispatchers.IO) { - val currentReactions = account.reactionTo(note, reaction) + runIOCatching { + val currentReactions = note.allReactionsOfContentByAuthor(userProfile(), reaction) if (currentReactions.isNotEmpty()) { account.delete(currentReactions) } else { @@ -321,14 +336,8 @@ class AccountViewModel( } fun reactToOrDelete(note: Note) { - viewModelScope.launch(Dispatchers.IO) { - val reaction = reactionChoices().first() - if (hasReactedTo(note, reaction)) { - deleteReactionTo(note, reaction) - } else { - reactTo(note, reaction) - } - } + val reaction = reactionChoices().first() + reactToOrDelete(note, reaction) } @Immutable @@ -342,7 +351,7 @@ class AccountViewModel( fun isNoteAcceptable( note: Note, - accountChoices: Account.LiveHiddenUsers, + accountChoices: HiddenUsersState.LiveHiddenUsers, followUsers: Set, ): NoteComposeReportState { checkNotInMainThread() @@ -383,13 +392,21 @@ class AccountViewModel( fun createIsHiddenFlow(note: Note): StateFlow = noteIsHiddenFlows.get(note) ?: combineTransform( - account.flowHiddenUsers, - account.liveKind3Follows, + account.hiddenUsers.flow, + account.kind3FollowList.flow, note.flow().author(), note.flow().metadata.stateFlow, note.flow().reports.stateFlow, ) { hiddenUsers, followingUsers, autor, metadata, reports -> emit(isNoteAcceptable(metadata.note, hiddenUsers, followingUsers.authors)) + }.onStart { + emit( + isNoteAcceptable( + note, + account.hiddenUsers.flow.value, + account.kind3FollowList.flow.value.authors, + ), + ) }.flowOn(Dispatchers.Default) .stateIn( viewModelScope, @@ -417,24 +434,6 @@ class AccountViewModel( noteMustShowExpandButtonFlows.put(note, it) } - fun hasReactedTo( - baseNote: Note, - reaction: String, - ): Boolean = account.hasReacted(baseNote, reaction) - - suspend fun deleteReactionTo( - note: Note, - reaction: String, - ) { - account.delete(account.reactionTo(note, reaction)) - } - - fun hasBoosted(baseNote: Note): Boolean = account.hasBoosted(baseNote) - - fun deleteBoostsTo(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.delete(account.boostsTo(note)) } - } - suspend fun calculateIfNoteWasZappedByAccount( zappedNote: Note, onWasZapped: (Boolean) -> Unit, @@ -444,42 +443,36 @@ class AccountViewModel( } } - suspend fun calculateZapAmount( - zappedNote: Note, - onZapAmount: (String) -> Unit, - ) { + suspend fun calculateZapAmount(zappedNote: Note): String = if (zappedNote.zapPayments.isNotEmpty()) { withContext(Dispatchers.Default) { - account.calculateZappedAmount(zappedNote) { onZapAmount(showAmount(it)) } + val it = account.calculateZappedAmount(zappedNote) + showAmount(it) } } else { - onZapAmount(showAmount(zappedNote.zapsAmount)) + showAmount(zappedNote.zapsAmount) } - } - suspend fun calculateZapraiser( - zappedNote: Note, - onZapraiserStatus: (ZapraiserStatus) -> Unit, - ) { + suspend fun calculateZapraiser(zappedNote: Note): ZapraiserStatus { val zapraiserAmount = zappedNote.event?.zapraiserAmount() ?: 0 - if (zappedNote.zapPayments.isNotEmpty()) { + return if (zappedNote.zapPayments.isNotEmpty()) { withContext(Dispatchers.Default) { - account.calculateZappedAmount(zappedNote) { newZapAmount -> - var percentage = newZapAmount.div(zapraiserAmount.toBigDecimal()).toFloat() + val newZapAmount = account.calculateZappedAmount(zappedNote) + var percentage = newZapAmount.div(zapraiserAmount.toBigDecimal()).toFloat() - if (percentage > 1) { - percentage = 1f + if (percentage > 1) { + percentage = 1f + } + + val newZapraiserProgress = percentage + val newZapraiserLeft = + if (percentage > 0.99) { + "0" + } else { + showAmount((zapraiserAmount * (1 - percentage)).toBigDecimal()) } - val newZapraiserProgress = percentage - val newZapraiserLeft = - if (percentage > 0.99) { - "0" - } else { - showAmount((zapraiserAmount * (1 - percentage)).toBigDecimal()) - } - onZapraiserStatus(ZapraiserStatus(newZapraiserProgress, newZapraiserLeft)) - } + ZapraiserStatus(newZapraiserProgress, newZapraiserLeft) } } else { var percentage = zappedNote.zapsAmount.div(zapraiserAmount.toBigDecimal()).toFloat() @@ -495,7 +488,8 @@ class AccountViewModel( } else { showAmount((zapraiserAmount * (1 - percentage)).toBigDecimal()) } - onZapraiserStatus(ZapraiserStatus(newZapraiserProgress, newZapraiserLeft)) + + ZapraiserStatus(newZapraiserProgress, newZapraiserLeft) } } @@ -523,22 +517,21 @@ class AccountViewModel( ) }.toMutableMap() - collectSuccessfulOperations( - items = zaps.filter { (it.request.event as? LnZapRequestEvent)?.isPrivateZap() == true }, - runRequestFor = { next, onReady -> - checkNotInMainThread() - - innerDecryptAmountMessage(next.request, next.response) { - onReady(DecryptedInfo(next.request, next.response, it)) + val results = + mapNotNullAsync( + zaps.filter { (it.request.event as? LnZapRequestEvent)?.isPrivateZap() == true }, + ) { next -> + val info = innerDecryptAmountMessage(next.request, next.response) + if (info != null) { + DecryptedInfo(next.request, next.response, info) + } else { + null } - }, - ) { - checkNotInMainThread() + } - it.forEach { decrypted -> initialResults[decrypted.zapRequest] = decrypted.info } + results.forEach { decrypted -> initialResults[decrypted.zapRequest] = decrypted.info } - onNewState(initialResults.values.toImmutableList()) - } + onNewState(initialResults.values.toImmutableList()) } } @@ -547,7 +540,7 @@ class AccountViewModel( .map { val request = it.request.event as? LnZapRequestEvent if (request?.isPrivateZap() == true) { - val cachedPrivateRequest = request.cachedPrivateZap() + val cachedPrivateRequest = account.privateZapsDecryptionCache.cachedPrivateZap(request) if (cachedPrivateRequest != null) { ZapAmountCommentNotification( LocalCache.getUserIfExists(cachedPrivateRequest.pubKey) ?: it.request.author, @@ -581,7 +574,7 @@ class AccountViewModel( .map { val request = it.first.event as? LnZapRequestEvent if (request?.isPrivateZap() == true) { - val cachedPrivateRequest = request.cachedPrivateZap() + val cachedPrivateRequest = account.privateZapsDecryptionCache.cachedPrivateZap(request) if (cachedPrivateRequest != null) { ZapAmountCommentNotification( LocalCache.getUserIfExists(cachedPrivateRequest.pubKey) ?: it.first.author, @@ -629,18 +622,19 @@ class AccountViewModel( ) }.toMutableMap() - collectSuccessfulOperations, DecryptedInfo>( - items = myList, - runRequestFor = { next, onReady -> - innerDecryptAmountMessage(next.first, next.second) { - onReady(DecryptedInfo(next.first, next.second, it)) + val decryptedInfo = + mapNotNullAsync(myList) { next -> + val info = innerDecryptAmountMessage(next.first, next.second) + if (info != null) { + DecryptedInfo(next.first, next.second, info) + } else { + null } - }, - ) { - it.forEach { decrypted -> initialResults[decrypted.zapRequest] = decrypted.info } + } - onNewState(initialResults.values.toImmutableList()) - } + decryptedInfo.forEach { decrypted -> initialResults[decrypted.zapRequest] = decrypted.info } + + onNewState(initialResults.values.toImmutableList()) } } @@ -650,44 +644,39 @@ class AccountViewModel( onNewState: (ZapAmountCommentNotification?) -> Unit, ) { viewModelScope.launch(Dispatchers.IO) { - innerDecryptAmountMessage(zapRequest, zapEvent, onNewState) + onNewState(innerDecryptAmountMessage(zapRequest, zapEvent)) } } - private fun innerDecryptAmountMessage( + private suspend fun innerDecryptAmountMessage( zapRequest: Note, zapEvent: Note?, - onReady: (ZapAmountCommentNotification) -> Unit, - ) { - checkNotInMainThread() - + ): ZapAmountCommentNotification? = (zapRequest.event as? LnZapRequestEvent)?.let { + val amount = showAmountInteger((zapEvent?.event as? LnZapEvent)?.amount) if (it.isPrivateZap()) { - decryptZap(zapRequest) { decryptedContent -> - val amount = (zapEvent?.event as? LnZapEvent)?.amount - val newAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey) - onReady( - ZapAmountCommentNotification( - newAuthor, - decryptedContent.content.ifBlank { null }, - showAmountInteger(amount), - ), + val decryptedContent = account.decryptZapOrNull(it) + if (decryptedContent != null) { + ZapAmountCommentNotification( + LocalCache.checkGetOrCreateUser(decryptedContent.pubKey), + decryptedContent.content.ifBlank { null }, + amount, + ) + } else { + ZapAmountCommentNotification( + zapRequest.author, + null, + amount, ) } } else { - val amount = (zapEvent?.event as? LnZapEvent)?.amount - if (!zapRequest.event?.content.isNullOrBlank() || amount != null) { - onReady( - ZapAmountCommentNotification( - zapRequest.author, - zapRequest.event?.content?.ifBlank { null }, - showAmountInteger(amount), - ), - ) - } + ZapAmountCommentNotification( + zapRequest.author, + zapRequest.event?.content?.ifBlank { null }, + amount, + ) } } - } fun zap( note: Note, @@ -700,25 +689,20 @@ class AccountViewModel( onProgress: (percent: Float) -> Unit, onPayViaIntent: (ImmutableList) -> Unit, zapType: LnZapEvent.ZapType? = null, - ) { - viewModelScope.launch(Dispatchers.IO) { - ZapPaymentHandler(account) - .zap( - note = note, - amountMilliSats = amountInMillisats, - pollOption = pollOption, - message = message, - context = context, - showErrorIfNoLnAddress = showErrorIfNoLnAddress, - okHttpClient = ::okHttpClientForMoney, - onError = onError, - onProgress = { - onProgress(it) - }, - onPayViaIntent = onPayViaIntent, - zapType = zapType ?: defaultZapType(), - ) - } + ) = runIOCatching { + ZapPaymentHandler(account).zap( + note = note, + amountMilliSats = amountInMillisats, + pollOption = pollOption, + message = message, + context = context, + showErrorIfNoLnAddress = showErrorIfNoLnAddress, + okHttpClient = ::okHttpClientForMoney, + onError = onError, + onProgress = { onProgress(it) }, + onPayViaIntent = onPayViaIntent, + zapType = zapType ?: defaultZapType(), + ) } fun report( @@ -726,85 +710,62 @@ class AccountViewModel( type: ReportType, content: String = "", ) { - viewModelScope.launch(Dispatchers.IO) { account.report(note, type, content) } + runIOCatching { account.report(note, type, content) } } fun report( user: User, type: ReportType, ) { - viewModelScope.launch(Dispatchers.IO) { + runIOCatching { account.report(user, type) account.hideUser(user.pubkeyHex) } } fun boost(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.boost(note) } + runIOCatching { account.boost(note) } } - fun removeEmojiPack( - usersEmojiList: Note, - emojiList: Note, - ) { - viewModelScope.launch(Dispatchers.IO) { account.removeEmojiPack(usersEmojiList, emojiList) } + fun removeEmojiPack(emojiPack: Note) { + runIOCatching { account.removeEmojiPack(emojiPack) } } - fun addEmojiPack( - usersEmojiList: Note, - emojiPack: Note, - ) { - viewModelScope.launch(Dispatchers.IO) { account.addEmojiPack(usersEmojiList, emojiPack) } + fun addEmojiPack(emojiPack: Note) { + runIOCatching { account.addEmojiPack(emojiPack) } } fun addMediaToGallery( hex: String, url: String, - relay: String?, + relay: NormalizedRelayUrl?, blurhash: String?, dim: DimensionTag?, hash: String?, mimeType: String?, ) { - viewModelScope.launch(Dispatchers.IO) { account.addToGallery(hex, url, relay, blurhash, dim, hash, mimeType) } + runIOCatching { account.addToGallery(hex, url, relay, blurhash, dim, hash, mimeType) } } - fun removefromMediaGallery(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.removeFromGallery(note) } + fun removeFromMediaGallery(note: Note) { + runIOCatching { account.removeFromGallery(note) } } - fun addPrivateBookmark(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.addBookmark(note, true) } - } + fun hashtagFollows(user: User): Note = LocalCache.getOrCreateAddressableNote(HashtagListEvent.createAddress(user.pubkeyHex)) - fun addPublicBookmark(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.addBookmark(note, false) } - } + fun bookmarks(user: User): Note = LocalCache.getOrCreateAddressableNote(BookmarkListEvent.createBookmarkAddress(user.pubkeyHex)) - fun removePrivateBookmark(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.removeBookmark(note, true) } - } + fun addPrivateBookmark(note: Note) = runIOCatching { account.addBookmark(note, true) } - fun removePublicBookmark(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.removeBookmark(note, false) } - } + fun addPublicBookmark(note: Note) = runIOCatching { account.addBookmark(note, false) } - fun isInPrivateBookmarks( - note: Note, - onReady: (Boolean) -> Unit, - ) { - account.isInPrivateBookmarks(note, onReady) - } + fun removePrivateBookmark(note: Note) = runIOCatching { account.removeBookmark(note, true) } - fun isInPublicBookmarks(note: Note): Boolean = account.isInPublicBookmarks(note) + fun removePublicBookmark(note: Note) = runIOCatching { account.removeBookmark(note, false) } - fun broadcast(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.broadcast(note) } - } + fun broadcast(note: Note) = runIOCatching { account.broadcast(note) } - fun timestamp(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.timestamp(note) } - } + fun timestamp(note: Note) = runIOCatching { account.otsState.timestamp(note) } var lastTimeItTriedToUpdateAttestations: Long = 0 @@ -813,16 +774,16 @@ class AccountViewModel( val now = TimeUtils.now() if (now - lastTimeItTriedToUpdateAttestations > TimeUtils.ONE_HOUR) { lastTimeItTriedToUpdateAttestations = now - viewModelScope.launch(Dispatchers.IO) { account.updateAttestations() } + runIOCatching { account.updateAttestations() } } } fun delete(notes: List) { - viewModelScope.launch(Dispatchers.IO) { account.delete(notes) } + runIOCatching { account.delete(notes) } } fun delete(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.delete(note) } + runIOCatching { account.delete(note) } } fun cachedDecrypt(note: Note): String? = account.cachedDecryptContent(note) @@ -832,55 +793,99 @@ class AccountViewModel( fun decrypt( note: Note, onReady: (String) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { account.decryptContent(note, onReady) } + ) = runIOCatching { + account.decryptContent(note)?.let { onReady(it) } } - fun decryptZap( - note: Note, - onReady: (Event) -> Unit, - ) { - account.decryptZapContentAuthor(note, onReady) + inline fun runIOCatching(crossinline action: suspend () -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + try { + action() + } catch (e: SignerExceptions.ReadOnlyException) { + toastManager.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_sign_events, + ) + } catch (e: SignerExceptions.UnauthorizedDecryptionException) { + toastManager.toast( + R.string.unauthorized_exception, + R.string.unauthorized_exception_description, + ) + } catch (e: SignerExceptions.SignerNotFoundException) { + toastManager.toast( + R.string.signer_not_found_exception, + R.string.signer_not_found_exception_description, + ) + } catch (e: SignerExceptions.TimedOutException) { + Log.w("AccountViewModel", "TimedOutException", e) + } catch (e: SignerExceptions.NothingToDecrypt) { + Log.w("AccountViewModel", "NothingToDecrypt", e) + } catch (e: SignerExceptions.CouldNotPerformException) { + Log.w("AccountViewModel", "CouldNotPerformException", e) + } catch (e: SignerExceptions.ManuallyUnauthorizedException) { + Log.w("AccountViewModel", "ManuallyUnauthorizedException", e) + } catch (e: SignerExceptions.AutomaticallyUnauthorizedException) { + Log.w("AccountViewModel", "AutomaticallyUnauthorizedException", e) + } catch (e: SignerExceptions.RunningOnBackgroundWithoutAutomaticPermissionException) { + Log.w("AccountViewModel", "TimedOutRunningOnBackgroundWithoutAutomaticPermissionExceptionException", e) + } + } } - fun follow(channel: Channel) { - viewModelScope.launch(Dispatchers.IO) { account.follow(channel) } + fun follow(community: AddressableNote) { + runIOCatching { account.follow(community) } } - fun unfollow(channel: Channel) { - viewModelScope.launch(Dispatchers.IO) { account.unfollow(channel) } + fun follow(channel: PublicChatChannel) { + runIOCatching { account.follow(channel) } + } + + fun follow(channel: EphemeralChatChannel) { + runIOCatching { account.follow(channel) } + } + + fun unfollow(community: AddressableNote) { + runIOCatching { account.unfollow(community) } + } + + fun unfollow(channel: PublicChatChannel) { + runIOCatching { account.unfollow(channel) } + } + + fun unfollow(channel: EphemeralChatChannel) { + runIOCatching { account.unfollow(channel) } } fun follow(user: User) { - viewModelScope.launch(Dispatchers.IO) { account.follow(user) } + runIOCatching { account.follow(user) } } fun unfollow(user: User) { - viewModelScope.launch(Dispatchers.IO) { account.unfollow(user) } + runIOCatching { account.unfollow(user) } } fun followGeohash(tag: String) { - viewModelScope.launch(Dispatchers.IO) { account.followGeohash(tag) } + runIOCatching { account.followGeohash(tag) } } fun unfollowGeohash(tag: String) { - viewModelScope.launch(Dispatchers.IO) { account.unfollowGeohash(tag) } + runIOCatching { account.unfollowGeohash(tag) } } fun followHashtag(tag: String) { - viewModelScope.launch(Dispatchers.IO) { account.followHashtag(tag) } + runIOCatching { account.followHashtag(tag) } } fun unfollowHashtag(tag: String) { - viewModelScope.launch(Dispatchers.IO) { account.unfollowHashtag(tag) } + runIOCatching { account.unfollowHashtag(tag) } } fun showWord(word: String) { - viewModelScope.launch(Dispatchers.IO) { account.showWord(word) } + runIOCatching { account.showWord(word) } } fun hideWord(word: String) { - viewModelScope.launch(Dispatchers.IO) { account.hideWord(word) } + runIOCatching { account.hideWord(word) } } fun isLoggedUser(pubkeyHex: HexKey?): Boolean = account.signer.pubKey == pubkeyHex @@ -894,28 +899,12 @@ class AccountViewModel( fun isFollowing(user: HexKey): Boolean = account.isFollowing(user) - fun hideSensitiveContent() { - viewModelScope.launch(Dispatchers.IO) { - account.updateShowSensitiveContent(false) - } - } - - fun disableContentWarnings() { - viewModelScope.launch(Dispatchers.IO) { - account.updateShowSensitiveContent(true) - } - } - - fun seeContentWarnings() { - viewModelScope.launch(Dispatchers.IO) { - account.updateShowSensitiveContent(null) - } - } - fun markDonatedInThisVersion() = account.markDonatedInThisVersion() fun dontTranslateFrom() = account.settings.syncedSettings.languages.dontTranslateFrom + fun dontTranslateFromFilteredBySpokenLanguages() = account.settings.syncedSettings.dontTranslateFromFilteredBySpokenLanguages() + fun translateTo() = account.settings.syncedSettings.languages.translateTo fun defaultZapType() = account.settings.syncedSettings.zaps.defaultZapType.value @@ -932,62 +921,62 @@ class AccountViewModel( fun filterSpamFromStrangers() = account.settings.syncedSettings.security.filterSpamFromStrangers - fun updateOptOutOptions( - warnReports: Boolean, - filterSpam: Boolean, - ) { - viewModelScope.launch(Dispatchers.IO) { - if (account.updateOptOutOptions(warnReports, filterSpam)) { + fun updateWarnReports(warnReports: Boolean) = runIOCatching { account.updateWarnReports(warnReports) } + + fun updateFilterSpam(filterSpam: Boolean) = + runIOCatching { + if (account.updateFilterSpam(filterSpam)) { LocalCache.antiSpam.active = filterSpamFromStrangers().value } } + + fun updateShowSensitiveContent(show: Boolean?) = runIOCatching { account.updateShowSensitiveContent(show) } + + fun changeReactionTypes( + reactionSet: List, + onDone: () -> Unit, + ) = runIOCatching { + account.changeReactionTypes(reactionSet) + onDone() } - fun unwrap( - event: GiftWrapEvent, - onReady: (Event) -> Unit, - ) { - account.unwrap(event, onReady) - } + fun updateZapAmounts( + amountSet: List, + selectedZapType: LnZapEvent.ZapType, + nip47Update: Nip47WalletConnect.Nip47URINorm?, + ) = runIOCatching { account.updateZapAmounts(amountSet, selectedZapType, nip47Update) } - fun unseal( - event: SealedRumorEvent, - onReady: (Event) -> Unit, - ) { - account.unseal(event, onReady) - } + fun toggleDontTranslateFrom(languageCode: String) = runIOCatching { account.toggleDontTranslateFrom(languageCode) } - fun show(user: User) { - viewModelScope.launch(Dispatchers.IO) { account.showUser(user.pubkeyHex) } - } + fun updateTranslateTo(languageCode: Locale) = runIOCatching { account.updateTranslateTo(languageCode) } - fun hide(user: User) { - viewModelScope.launch(Dispatchers.IO) { account.hideUser(user.pubkeyHex) } - } + fun prefer( + source: String, + target: String, + preference: String, + ) = runIOCatching { account.prefer(source, target, preference) } - fun hide(word: String) { - viewModelScope.launch(Dispatchers.IO) { account.hideWord(word) } - } + fun show(user: User) = runIOCatching { account.showUser(user.pubkeyHex) } - fun showUser(pubkeyHex: String) { - viewModelScope.launch(Dispatchers.IO) { account.showUser(pubkeyHex) } - } + fun hide(user: User) = runIOCatching { account.hideUser(user.pubkeyHex) } - fun createStatus(newStatus: String) { - viewModelScope.launch(Dispatchers.IO) { account.createStatus(newStatus) } - } + fun hide(word: String) = runIOCatching { account.hideWord(word) } + + fun showUser(pubkeyHex: String) = runIOCatching { account.showUser(pubkeyHex) } + + fun createStatus(newStatus: String) = runIOCatching { account.createStatus(newStatus) } fun updateStatus( address: Address, newStatus: String, ) { - viewModelScope.launch(Dispatchers.IO) { + runIOCatching { account.updateStatus(LocalCache.getOrCreateAddressableNote(address), newStatus) } } fun deleteStatus(address: Address) { - viewModelScope.launch(Dispatchers.IO) { + runIOCatching { account.deleteStatus(LocalCache.getOrCreateAddressableNote(address)) } } @@ -1001,7 +990,7 @@ class AccountViewModel( } } - suspend fun loadReactionTo(note: Note?): String? { + fun loadReactionTo(note: Note?): String? { if (note == null) return null return note.getReactionBy(userProfile()) @@ -1018,8 +1007,8 @@ class AccountViewModel( Nip05NostrAddressVerifier() .verifyNip05( nip05, - okttpClient = { - Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP05(it)) + okHttpClient = { + app.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForNIP05(it)) }, onSuccess = { // Marks user as verified @@ -1047,17 +1036,15 @@ class AccountViewModel( } } - fun createRumor(template: EventTemplate) = account.signer.assembleRumor(template) - fun retrieveRelayDocument( - dirtyUrl: String, + relay: NormalizedRelayUrl, onInfo: (Nip11RelayInformation) -> Unit, - onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit, + onError: (NormalizedRelayUrl, Nip11Retriever.ErrorCode, String?) -> Unit, ) { viewModelScope.launch(Dispatchers.IO) { Nip11CachedRetriever.loadRelayInfo( - dirtyUrl, - okHttpClient = ::okHttpClientForDirty, + relay, + okHttpClient = { okHttpClientForClean(relay) }, onInfo, onError, ) @@ -1081,7 +1068,7 @@ class AccountViewModel( fun getUserIfExists(hex: HexKey): User? = LocalCache.getUserIfExists(hex) - private suspend fun checkGetOrCreateNote(key: HexKey): Note? = LocalCache.checkGetOrCreateNote(key) + private fun checkGetOrCreateNote(key: HexKey): Note? = LocalCache.checkGetOrCreateNote(key) override suspend fun getOrCreateNote(key: HexKey): Note = LocalCache.getOrCreateNote(key) @@ -1100,7 +1087,7 @@ class AccountViewModel( var note = checkGetOrCreateNote(event.id) if (note == null) { - LocalCache.verifyAndConsume(event, null) + LocalCache.justConsume(event, null, false) note = checkGetOrCreateNote(event.id) } @@ -1110,16 +1097,7 @@ class AccountViewModel( fun getNoteIfExists(hex: HexKey): Note? = LocalCache.getNoteIfExists(hex) - override suspend fun checkGetOrCreateAddressableNote(key: HexKey): AddressableNote? = LocalCache.checkGetOrCreateAddressableNote(key) - - fun checkGetOrCreateAddressableNote( - key: HexKey, - onResult: (AddressableNote?) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { onResult(checkGetOrCreateAddressableNote(key)) } - } - - suspend fun getOrCreateAddressableNote(key: Address): AddressableNote = LocalCache.getOrCreateAddressableNote(key) + override suspend fun getOrCreateAddressableNote(address: Address): AddressableNote = LocalCache.getOrCreateAddressableNote(address) fun getOrCreateAddressableNote( key: Address, @@ -1147,7 +1125,7 @@ class AccountViewModel( ) { onResult( withContext(Dispatchers.Default) { - LocalCache.findEarliestOtsForNote(note, account::otsResolver) + LocalCache.findEarliestOtsForNote(note, account.otsResolverBuilder) }, ) } @@ -1165,16 +1143,24 @@ class AccountViewModel( ) } - private suspend fun checkGetOrCreateChannel(key: HexKey): Channel? = LocalCache.checkGetOrCreateChannel(key) + fun checkGetOrCreatePublicChatChannel(key: HexKey): PublicChatChannel? = LocalCache.getOrCreatePublicChatChannel(key) + + fun checkGetOrCreateLiveActivityChannel(key: Address): LiveActivitiesChannel? = LocalCache.getOrCreateLiveChannel(key) + + fun checkGetOrCreateEphemeralChatChannel(key: RoomId): EphemeralChatChannel? = LocalCache.getOrCreateEphemeralChannel(key) fun checkGetOrCreateChannel( key: HexKey, onResult: (Channel?) -> Unit, ) { - viewModelScope.launch(Dispatchers.IO) { onResult(checkGetOrCreateChannel(key)) } + viewModelScope.launch(Dispatchers.IO) { onResult(checkGetOrCreatePublicChatChannel(key)) } } - fun getChannelIfExists(hex: HexKey): Channel? = LocalCache.getChannelIfExists(hex) + fun getPublicChatChannelIfExists(hex: HexKey) = LocalCache.getPublicChatChannelIfExists(hex) + + fun getEphemeralChatChannelIfExists(key: RoomId) = LocalCache.getEphemeralChatChannelIfExists(key) + + fun getLiveActivityChannelIfExists(key: Address) = LocalCache.getLiveActivityChannelIfExists(key) fun loadParticipants( participants: List, @@ -1201,44 +1187,20 @@ class AccountViewModel( onReady: (ImmutableList) -> Unit, ) { viewModelScope.launch(Dispatchers.Default) { - onReady( - hexList - .mapNotNull { hex -> checkGetOrCreateUser(hex) } - .sortedBy { account.isFollowing(it) } - .reversed() - .toImmutableList(), - ) + onReady(loadUsersSync(hexList).toImmutableList()) } } - fun loadUsers( - event: GeneralListEvent, - onReady: (ImmutableList) -> Unit, - ) { - viewModelScope.launch(Dispatchers.Default) { - account.decryptPeopleList(event) { privateTagList -> - onReady( - (event.taggedUserIds() + event.filterUsers(privateTagList)) - .toSet() - .mapNotNull { hex -> checkGetOrCreateUser(hex) } - .sortedBy { account.isFollowing(it) } - .reversed() - .toImmutableList(), - ) - } - } - } + fun loadUsersSync(hexList: List): List = + hexList + .mapNotNull { hex -> checkGetOrCreateUser(hex) } + .sortedBy { account.isFollowing(it) } + .reversed() - fun checkVideoIsOnline( - videoUrl: String, - onDone: (Boolean) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { - onDone( - OnlineChecker.isOnline(videoUrl, ::okHttpClientForVideo), - ) + suspend fun checkVideoIsOnline(videoUrl: String): Boolean = + withContext(Dispatchers.IO) { + OnlineChecker.isOnline(videoUrl, ::okHttpClientForVideo) } - } fun loadAndMarkAsRead( routeForLastRead: String, @@ -1259,110 +1221,121 @@ class AccountViewModel( return onIsNew } - fun markAllAsRead( - notes: ImmutableList, - accountViewModel: AccountViewModel, - onDone: () -> Unit, - ) { + fun markAllChatNotesAsRead(notes: List) { viewModelScope.launch(Dispatchers.IO) { for (note in notes) { - note.event?.createdAt?.let { date -> - val route = routeFor(note, accountViewModel.account.userProfile()) - route?.let { - if (route is Route.Room) { - account.markAsRead("Room/${route.id}", date) - } else if (route is Route.Channel) { - account.markAsRead("Channel/${route.id}", date) - } - } + val noteEvent = note.event + if (noteEvent is IsInPublicChatChannel) { + account.markAsRead("Channel/${noteEvent.channelId()}", noteEvent.createdAt) + } else if (noteEvent is ChatroomKeyable) { + account.markAsRead("Room/${noteEvent.chatroomKey(account.signer.pubKey).hashCode()}", noteEvent.createdAt) } } - - onDone() } } - fun createChatRoomFor( - user: User, - then: (Int) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { - val withKey = ChatroomKey(persistentSetOf(user.pubkeyHex)) - account.userProfile().createChatroom(withKey) - then(withKey.hashCode()) - } - } - - fun setTorSettings(newTorSettings: TorSettings) = - viewModelScope.launch(Dispatchers.IO) { - // Only restart relay connections if port or type changes - if (account.settings.setTorSettings(newTorSettings)) { - Amethyst.instance.serviceManager.forceRestart() - } - } - - fun forceRestartServices() = - viewModelScope.launch(Dispatchers.IO) { - Amethyst.instance.serviceManager.setAccountAndRestart(account) - } - - fun justStart() = - viewModelScope.launch(Dispatchers.IO) { - Amethyst.instance.serviceManager.justStartIfItHasAccount() - } - - fun justPause() = - viewModelScope.launch(Dispatchers.IO) { - Amethyst.instance.serviceManager.cleanObservers() - Amethyst.instance.serviceManager.pauseForGood() - } - - fun pauseAndLogOff() = - viewModelScope.launch(Dispatchers.IO) { - Amethyst.instance.serviceManager.cleanObservers() - Amethyst.instance.serviceManager.pauseAndLogOff() - } - - fun changeProxyPort(port: Int) = - viewModelScope.launch(Dispatchers.IO) { - Amethyst.instance.serviceManager.forceRestart() - } + fun setTorSettings(newTorSettings: TorSettings) = runIOCatching { account.settings.setTorSettings(newTorSettings) } class Factory( val accountSettings: AccountSettings, val settings: SharedSettingsState, + val app: Amethyst, ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): AccountViewModel = AccountViewModel(accountSettings, settings) as AccountViewModel + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = AccountViewModel(accountSettings, settings, app) as T } - private var collectorJob: Job? = null - private val bundlerInsert = BundledInsert>(3000, Dispatchers.Default) - init { Log.d("Init", "AccountViewModel") - collectorJob = - viewModelScope.launch(Dispatchers.Default) { - feedStates.init() - // awaits for init to finish before starting to capture new events. - LocalCache.live.newEventBundles.collect { newNotes -> + viewModelScope.launch(Dispatchers.Default) { + feedStates.init() + // awaits for init to finish before starting to capture new events. + LocalCache.live.newEventBundles.collect { newNotes -> + if (isDebug) { Log.d( "Rendering Metrics", "Update feeds ${this@AccountViewModel} for ${account.userProfile().toBestDisplayName()} with ${newNotes.size} new notes", ) + } + logTime("AccountViewModel newEventBundle Update with ${newNotes.size} new notes") { feedStates.updateFeedsWith(newNotes) upgradeAttestations() + viewModelScope.launch(Dispatchers.Default) { + newNotesPreProcessor.runNew(newNotes) + } } } + } + + viewModelScope.launch(Dispatchers.Default) { + LocalCache.live.deletedEventBundles.collect { newNotes -> + if (isDebug) { + Log.d( + "Rendering Metrics", + "Update feeds ${this@AccountViewModel} for ${account.userProfile().toBestDisplayName()} with ${newNotes.size} new notes", + ) + } + logTime("AccountViewModel deletedEventBundle Update with ${newNotes.size} new notes") { + newNotesPreProcessor.runDeleted(newNotes) + } + } + } } override fun onCleared() { Log.d("Init", "AccountViewModel onCleared") feedStates.destroy() - bundlerInsert.cancel() - collectorJob?.cancel() super.onCleared() } + fun sendVoiceReply( + note: Note, + recording: RecordingResult, + context: Context, + ) { + if (isWriteable()) { + val hint = note.toEventHint() + if (hint == null) return + + runIOCatching { + val uploader = UploadOrchestrator() + val result = + uploader.upload( + uri = recording.file.toUri(), + mimeType = recording.mimeType, + alt = null, + contentWarningReason = null, + compressionQuality = CompressorQuality.UNCOMPRESSED, + server = account.settings.defaultFileServer, + account = account, + context = context, + ) + + if (result is UploadingState.Finished && result.result is UploadOrchestrator.OrchestratorResult.ServerResult) { + account.sendVoiceReplyMessage( + result.result.url, + result.result.fileHeader.mimeType ?: recording.mimeType, + result.result.fileHeader.hash, + recording.duration, + recording.amplitudes, + hint, + ) + } else if (result is UploadingState.Error) { + toastManager.toast( + R.string.failed_to_upload_media_no_details, + result.errorResource, + *result.params, + ) + } + } + } else { + toastManager.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_reply, + ) + } + } + fun loadThumb( context: Context, thumbUri: String, @@ -1415,8 +1388,11 @@ class AccountViewModel( } if (isWriteable()) { - if (hasBoosted(baseNote)) { - deleteBoostsTo(baseNote) + val boosts = baseNote.boostedBy(userProfile()) + if (boosts.isNotEmpty()) { + runIOCatching { + account.delete(boosts) + } } else { onMore() } @@ -1428,10 +1404,6 @@ class AccountViewModel( } } - fun dismissPaymentRequest(request: Account.PaymentRequest) { - viewModelScope.launch(Dispatchers.IO) { account.dismissPaymentRequest(request) } - } - fun meltCashu( token: CashuToken, context: Context, @@ -1440,15 +1412,26 @@ class AccountViewModel( val lud16 = account.userProfile().info?.lud16 if (lud16 != null) { viewModelScope.launch(Dispatchers.IO) { - CashuProcessor() - .melt( - token, - lud16, - okHttpClient = ::okHttpClientForMoney, - onSuccess = { title, message -> onDone(title, message) }, - onError = { title, message -> onDone(title, message) }, - context, + try { + val meltResult = MeltProcessor().melt(token, lud16, ::okHttpClientForMoney, context) + onDone( + stringRes(context, R.string.cashu_successful_redemption), + stringRes( + context, + R.string.cashu_successful_redemption_explainer, + token.totalAmount.toString(), + meltResult.fees.toString(), + ), ) + } catch (e: LightningAddressResolver.LightningAddressError) { + onDone(e.title, e.msg) + } catch (e: Exception) { + if (e is kotlin.coroutines.cancellation.CancellationException) throw e + onDone( + stringRes(context, R.string.cashu_failed_redemption), + stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message), + ) + } } } else { onDone( @@ -1462,106 +1445,114 @@ class AccountViewModel( } } - fun unwrapIfNeeded( - event: Event?, - onReady: (Note) -> Unit, - ) { - when (event) { - is GiftWrapEvent -> { - event.innerEventId?.let { - val existingNote = LocalCache.getNoteIfExists(it) - if (existingNote != null) { - unwrapIfNeeded(existingNote.event, onReady) - } else { - event.unwrap(account.signer) { - LocalCache.verifyAndConsume(it, null) - unwrapIfNeeded(it, onReady) - } - } - } ?: run { - event.unwrap(account.signer) { - val existingNote = LocalCache.getNoteIfExists(it.id) - if (existingNote != null) { - unwrapIfNeeded(existingNote.event, onReady) - } else { - LocalCache.verifyAndConsume(it, null) - unwrapIfNeeded(it, onReady) - } - } - } + private suspend fun unwrapGiftWrap(event: GiftWrapEvent): Note? { + val cacheInnerEventId = event.innerEventId + return if (cacheInnerEventId != null) { + val existingNoteEvent = LocalCache.getNoteIfExists(cacheInnerEventId)?.event + if (existingNoteEvent != null) { + unwrapIfNeeded(existingNoteEvent) + } else { + val newEvent = event.unwrapOrNull(account.signer) + + if (newEvent == null) return null + + // clear the encrypted payload to save memory + LocalCache.getOrCreateNote(event.id).event = event.copyNoContent() + + LocalCache.justConsume(newEvent, null, false) + + unwrapIfNeeded(newEvent) } - is SealedRumorEvent -> { - event.innerEventId?.let { - val existingNote = LocalCache.getNoteIfExists(it) - if (existingNote != null) { - unwrapIfNeeded(existingNote.event, onReady) - } else { - event.unseal(account.signer) { - // this is not verifiable - LocalCache.justConsume(it, null) - unwrapIfNeeded(it, onReady) - } - } - } ?: run { - event.unseal(account.signer) { - val existingNote = LocalCache.getNoteIfExists(it.id) - if (existingNote != null) { - unwrapIfNeeded(existingNote.event, onReady) - } else { - // this is not verifiable - LocalCache.justConsume(it, null) - unwrapIfNeeded(it, onReady) - } - } - } - } - else -> { - event?.id?.let { - LocalCache.getNoteIfExists(it)?.let { - onReady(it) - } - } + } else { + val newEvent = event.unwrapThrowing(account.signer) + // clear the encrypted payload to save memory + LocalCache.getOrCreateNote(event.id).event = event.copyNoContent() + + val existingNoteEvent = LocalCache.getNoteIfExists(newEvent.id)?.event + + if (existingNoteEvent != null) { + unwrapIfNeeded(existingNoteEvent) + } else { + LocalCache.justConsume(newEvent, null, false) + unwrapIfNeeded(newEvent) } } } + private suspend fun unwrapSeal(event: SealedRumorEvent): Note? { + val cacheInnerEventId = event.innerEventId + return if (cacheInnerEventId != null) { + val existingNoteEvent = LocalCache.getNoteIfExists(cacheInnerEventId)?.event + if (existingNoteEvent != null) { + unwrapIfNeeded(existingNoteEvent) + } else { + val newEvent = event.unsealThrowing(account.signer) + // clear the encrypted payload to save memory + LocalCache.getOrCreateNote(event.id).event = event.copyNoContent() + + // this is not verifiable + LocalCache.justConsume(newEvent, null, true) + unwrapIfNeeded(newEvent) + } + } else { + val newEvent = event.unsealThrowing(account.signer) + // clear the encrypted payload to save memory + LocalCache.getOrCreateNote(event.id).event = event.copyNoContent() + + val existingNoteEvent = LocalCache.getNoteIfExists(newEvent.id)?.event + if (existingNoteEvent != null) { + unwrapIfNeeded(existingNoteEvent) + } else { + // this is not verifiable + LocalCache.justConsume(newEvent, null, true) + unwrapIfNeeded(newEvent) + } + } + } + + private suspend fun unwrapIfNeeded(event: Event): Note? = + when (event) { + is GiftWrapEvent -> unwrapGiftWrap(event) + is SealedRumorEvent -> unwrapSeal(event) + else -> LocalCache.getNoteIfExists(event.id) + } + fun unwrapIfNeeded( note: Note?, - onReady: (Note) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { - unwrapIfNeeded(note?.event) { - onReady(it) + onReady: (Note) -> Unit = {}, + ) = runIOCatching { + val noteEvent = note?.event + if (noteEvent != null) { + val resultingNote = unwrapIfNeeded(noteEvent) + if (resultingNote != null && resultingNote != note) { + onReady(resultingNote) } } } - fun proxyPortFor(url: String): Int? = Amethyst.instance.okHttpClients.getCurrentProxyPort(account.shouldUseTorForVideoDownload(url)) + fun proxyPortFor(url: String): Int? = app.okHttpClients.getCurrentProxyPort(account.privacyState.shouldUseTorForVideoDownload(url)) - fun okHttpClientForNip96(url: String): OkHttpClient = Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(url)) + fun okHttpClientForNip96(url: String): OkHttpClient = app.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(url)) - fun okHttpClientForImage(url: String): OkHttpClient = Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForImageDownload()) + fun okHttpClientForImage(url: String): OkHttpClient = app.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForImageDownload(url)) - fun okHttpClientForVideo(url: String): OkHttpClient = Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForVideoDownload(url)) + fun okHttpClientForVideo(url: String): OkHttpClient = app.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForVideoDownload(url)) - fun okHttpClientForMoney(url: String): OkHttpClient = Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForMoneyOperations(url)) + fun okHttpClientForMoney(url: String): OkHttpClient = app.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForMoneyOperations(url)) - fun okHttpClientForPreview(url: String): OkHttpClient = Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForPreviewUrl(url)) + fun okHttpClientForPreview(url: String): OkHttpClient = app.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForPreviewUrl(url)) - fun okHttpClientForDirty(url: String): OkHttpClient = Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForDirty(url)) + fun okHttpClientForClean(url: NormalizedRelayUrl): OkHttpClient = app.okHttpClients.getHttpClient(account.torRelayState.shouldUseTorForClean(url)) - fun okHttpClientForTrustedRelays(url: String): OkHttpClient = Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForTrustedRelays()) + fun okHttpClientForTrustedRelays(url: String): OkHttpClient = app.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForTrustedRelays()) + + fun dataSources() = app.sources suspend fun deleteDraft(draftTag: String) { account.deleteDraft(draftTag) } - suspend fun createTempDraftNote( - noteEvent: DraftEvent, - onReady: (Note?) -> Unit, - ) { - draftNoteCache.update(noteEvent, onReady) - } + suspend fun createTempDraftNote(noteEvent: DraftEvent): Note? = draftNoteCache.update(noteEvent) fun createTempDraftNote( innerEvent: Event, @@ -1578,7 +1569,7 @@ class AccountViewModel( } fun requestDVMContentDiscovery( - dvmPublicKey: String, + dvmPublicKey: User, onReady: (event: Note) -> Unit, ) { viewModelScope.launch(Dispatchers.IO) { @@ -1613,12 +1604,11 @@ class AccountViewModel( fun sendZapPaymentRequestFor( bolt11: String, zappedNote: Note?, - onSent: () -> Unit, + onSent: () -> Unit = {}, onResponse: (Response?) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { - account.sendZapPaymentRequestFor(bolt11, zappedNote, onSent, onResponse) - } + ) = runIOCatching { + account.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse) + onSent() } fun getRelayListFor(user: User): AdvertisedRelayListEvent? = (getRelayListNoteFor(user)?.event as? AdvertisedRelayListEvent?) @@ -1651,68 +1641,109 @@ class AccountViewModel( } fun sendSats( - lnaddress: String, + lnAddress: String, + user: User, milliSats: Long, message: String, - toUserPubKeyHex: HexKey, - onSuccess: (String) -> Unit, + onNewInvoice: (String) -> Unit, onError: (String, String) -> Unit, onProgress: (percent: Float) -> Unit, context: Context, ) { viewModelScope.launch(Dispatchers.IO) { - if (defaultZapType() == LnZapEvent.ZapType.NONZAP) { - LightningAddressResolver() - .lnAddressInvoice( - lnaddress, - milliSats, - message, - null, + try { + val zapRequest = + if (defaultZapType() != LnZapEvent.ZapType.NONZAP) { + account.createZapRequestFor(user, message, defaultZapType()) + } else { + null + } + + val invoice = + LightningAddressResolver().lnAddressInvoice( + lnAddress = lnAddress, + milliSats = milliSats, + message = message, + nostrRequest = zapRequest, okHttpClient = ::okHttpClientForMoney, - onSuccess = onSuccess, - onError = onError, onProgress = onProgress, context = context, ) - } else { - account.createZapRequestFor(toUserPubKeyHex, message, defaultZapType()) { zapRequest -> - LocalCache.justConsume(zapRequest, null) - LightningAddressResolver() - .lnAddressInvoice( - lnaddress, - milliSats, - message, - zapRequest.toJson(), - okHttpClient = ::okHttpClientForMoney, - onSuccess = onSuccess, - onError = onError, - onProgress = onProgress, - context = context, - ) - } + + onNewInvoice(invoice) + } catch (e: LightningAddressResolver.LightningAddressError) { + onError(e.title, e.msg) + } catch (e: Exception) { + if (e is CancellationException) throw e + onError("Error", e.message ?: "Unknown error") } } } - suspend fun findUsersStartingWithSync(prefix: String) = LocalCache.findUsersStartingWith(prefix, account) + fun saveMediaToGallery( + videoUri: String?, + mimeType: String?, + localContext: Context, + ) { + viewModelScope.launch { + MediaSaverToDisk.saveDownloadingIfNeeded( + videoUri = videoUri, + okHttpClient = ::okHttpClientForVideo, + mimeType = mimeType, + localContext = localContext, + onSuccess = { + toastManager.toast(R.string.video_saved_to_the_gallery, R.string.video_saved_to_the_gallery) + }, + onError = { + toastManager.toast(R.string.failed_to_save_the_video, null, it) + }, + ) + } + } - fun relayStatusFlow() = Amethyst.instance.client.relayStatusFlow() + fun findUsersStartingWithSync(prefix: String) = LocalCache.findUsersStartingWith(prefix, account) + + fun relayStatusFlow() = app.client.relayStatusFlow() + + fun convertAccounts(loggedInAccounts: List?): Set = + loggedInAccounts + ?.mapNotNull { + try { + it.npub.bechToBytes().toHexKey() + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + }?.toSet() ?: emptySet() + + val trustedAccounts: StateFlow> = + LocalPreferences + .accountsFlow() + .map { loggedInAccounts -> + convertAccounts(loggedInAccounts) + }.onStart { + emit(convertAccounts(LocalPreferences.allSavedAccounts())) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + emptySet(), + ) val draftNoteCache = CachedDraftNotes(this) class CachedDraftNotes( val accountViewModel: AccountViewModel, ) : GenericBaseCacheAsync(20) { - override suspend fun compute( - key: DraftEvent, - onReady: (Note?) -> Unit, - ) = withContext(Dispatchers.IO) { - key.cachedDraft(accountViewModel.account.signer) { - val author = LocalCache.getOrCreateUser(key.pubKey) - val note = accountViewModel.createTempDraftNote(it, author) - onReady(note) + override suspend fun compute(key: DraftEvent): Note? = + withContext(Dispatchers.IO) { + val decrypted = accountViewModel.account.draftsDecryptionCache.cachedDraft(key) + if (decrypted != null) { + val author = LocalCache.getOrCreateUser(key.pubKey) + accountViewModel.createTempDraftNote(decrypted, author) + } else { + null + } } - } } val bechLinkCache = CachedLoadedBechLink(this) @@ -1729,7 +1760,7 @@ class AccountViewModel( is NSec -> {} is NPub -> {} is NProfile -> {} - is com.vitorpamplona.quartz.nip19Bech32.entities.Note -> { + is NNote -> { LocalCache.checkGetOrCreateNote(parsed.hex)?.let { note -> returningNote = note } @@ -1744,7 +1775,7 @@ class AccountViewModel( val baseNote = LocalCache.getOrCreateNote(parsed.event) if (baseNote.event == null) { launch(Dispatchers.Default) { - LocalCache.verifyAndConsume(parsed.event, null) + LocalCache.justConsume(parsed.event, null, false) } } @@ -1771,8 +1802,13 @@ class AccountViewModel( val nip19: Nip19Parser.ParseReturn, ) +var mockedCache: AccountViewModel? = null + +@SuppressLint("ViewModelConstructorInComposable") @Composable fun mockAccountViewModel(): AccountViewModel { + mockedCache?.let { return it } + val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel() sharedPreferencesViewModel.init() @@ -1787,11 +1823,19 @@ fun mockAccountViewModel(): AccountViewModel { ), ), sharedPreferencesViewModel.sharedPrefs, - ) + Amethyst(), + ).also { + mockedCache = it + } } +var vitorCache: AccountViewModel? = null + +@SuppressLint("ViewModelConstructorInComposable") @Composable fun mockVitorAccountViewModel(): AccountViewModel { + mockedCache?.let { return it } + val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel() sharedPreferencesViewModel.init() @@ -1804,5 +1848,8 @@ fun mockVitorAccountViewModel(): AccountViewModel { ), ), sharedPreferencesViewModel.sharedPrefs, - ) + Amethyst(), + ).also { + vitorCache = it + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt new file mode 100644 index 0000000000..dd062d78d2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -0,0 +1,393 @@ +/** + * 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.ui.screen.loggedIn + +import android.util.Log +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.privateChats.ChatroomList +import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.IEvent +import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip37Drafts.DraftEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.PrivateZapCache +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import kotlinx.coroutines.CancellationException + +class EventProcessor( + private val account: Account, + private val cache: LocalCache, +) { + private val chatHandler = ChatHandler(account.chatroomList) + private val otsHandler = OtsEventHandler(account) + + private val draftHandler = DraftEventHandler(account, cache) + + private val giftWrapHandler = GiftWrapEventHandler(account, cache, this) + private val sealHandler = SealedRumorEventHandler(account, cache, this) + + private val zapRequest = LnZapRequestEventHandler(account.privateZapsDecryptionCache) + private val zapEvent = LnZapEventHandler(account.privateZapsDecryptionCache) + + suspend fun consume(note: Note) { + note.event?.let { event -> + try { + consumeEvent(event, note, note) + } catch (e: Exception) { + Log.e("EventProcessor", "Error processing note", e) + } + } + } + + internal suspend fun consumeEvent( + event: Event, + eventNote: Note, + publicNote: Note, + ) { + when (event) { + is ChatroomKeyable -> chatHandler.add(event, eventNote, publicNote) + is OtsEvent -> otsHandler.add(event, eventNote, publicNote) + is DraftEvent -> draftHandler.add(event, eventNote, publicNote) + is GiftWrapEvent -> giftWrapHandler.add(event, eventNote, publicNote) + is SealedRumorEvent -> sealHandler.add(event, eventNote, publicNote) + is LnZapRequestEvent -> zapRequest.add(event, eventNote, publicNote) + } + } + + suspend fun delete(note: Note) { + note.event?.let { event -> + try { + deleteEvent(event, note) + } catch (e: Exception) { + Log.e("EventProcessor", "Error deleting note", e) + } + } + } + + internal suspend fun deleteEvent( + event: Event, + note: Note, + ) { + when (event) { + is ChatroomKeyable -> chatHandler.delete(event, note) + is OtsEvent -> otsHandler.delete(event, note) + is DraftEvent -> draftHandler.delete(event, note) + is GiftWrapEvent -> giftWrapHandler.delete(event, note) + is SealedRumorEvent -> sealHandler.delete(event, note) + is LnZapRequestEvent -> zapRequest.delete(event, note) + is LnZapEvent -> zapEvent.delete(event, note) + } + } + + suspend fun runNew(newNotes: Set) { + try { + newNotes.forEach { consume(it) } + handleDeletedDrafts(newNotes) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("EventProcessor", "Error processing batch", e) + } + } + + suspend fun runDeleted(notes: Set) { + try { + notes.forEach { delete(it) } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("EventProcessor", "Error deleting batch", e) + } + } + + private suspend fun handleDeletedDrafts(newNotes: Set) { + val deletedDrafts = + newNotes.mapNotNull { note -> + val event = note.event + if (event is DraftEvent && + event.isDeleted() && + !cache.deletionIndex.hasBeenDeleted(event) + ) { + note + } else { + null + } + } + + if (deletedDrafts.isNotEmpty()) { + Log.w("EventProcessor", "Deleting ${deletedDrafts.size} draft notes") + account.delete(deletedDrafts) + } + } +} + +interface EventHandler { + suspend fun add( + event: T, + eventNote: Note, + publicNote: Note, + ) {} + + suspend fun delete( + event: T, + eventNote: Note, + ) {} +} + +class ChatHandler( + private val chatroomList: ChatroomList, +) : EventHandler { + override suspend fun add( + event: ChatroomKeyable, + eventNote: Note, + publicNote: Note, + ) { + chatroomList.add(event, eventNote) + } + + override suspend fun delete( + event: ChatroomKeyable, + eventNote: Note, + ) { + chatroomList.delete(event, eventNote) + } +} + +class OtsEventHandler( + private val account: Account, +) : EventHandler { + override suspend fun add( + event: OtsEvent, + eventNote: Note, + publicNote: Note, + ) { + Amethyst.instance.otsVerifCache.cacheVerify(event, account.otsResolverBuilder) + } +} + +class DraftEventHandler( + private val account: Account, + private val cache: LocalCache, +) : EventHandler { + override suspend fun add( + event: DraftEvent, + eventNote: Note, + publicNote: Note, + ) { + if (event.pubKey == account.signer.pubKey && !event.isDeleted()) { + val rumor = account.draftsDecryptionCache.preCachedDraft(event) ?: account.draftsDecryptionCache.cachedDraft(event) + rumor?.let { indexDraftAsRealEvent(eventNote, it) } + } + } + + fun indexDraftAsRealEvent( + draftEventWrap: Note, + rumor: Event, + ) { + draftEventWrap.replyTo = cache.computeReplyTo(rumor) + draftEventWrap.replyTo?.forEach { it.addReply(draftEventWrap) } + + when (rumor) { + is ChatroomKeyable -> account.chatroomList.add(rumor, draftEventWrap) + is EphemeralChatEvent -> { + rumor.roomId()?.let { roomId -> + val channel = cache.getOrCreateEphemeralChannel(roomId) + channel.addNote(draftEventWrap, null) + } + } + is ChannelMessageEvent -> { + rumor.channelId()?.let { channelId -> + val channel = cache.checkGetOrCreatePublicChatChannel(channelId) + channel?.addNote(draftEventWrap, null) + } + } + is LiveActivitiesChatMessageEvent -> { + rumor.activityAddress()?.let { channelId -> + val channel = cache.getOrCreateLiveChannel(channelId) + channel.addNote(draftEventWrap, null) + } + } + } + } +} + +class GiftWrapEventHandler( + private val account: Account, + private val cache: LocalCache, + private val eventProcessor: EventProcessor, +) : EventHandler { + override suspend fun add( + event: GiftWrapEvent, + eventNote: Note, + publicNote: Note, + ) { + if (event.recipientPubKey() != account.signer.pubKey) return + + val innerGiftId = event.innerEventId + if (innerGiftId == null) { + processNewGiftWrap(event, eventNote, publicNote) + } else { + processExistingGiftWrap(innerGiftId, publicNote) + } + } + + override suspend fun delete( + event: GiftWrapEvent, + eventNote: Note, + ) { + if (event.recipientPubKey() != account.signer.pubKey) return + + event.innerEventId?.let { innerGiftId -> + val innerGiftNote = cache.getNoteIfExists(innerGiftId) + innerGiftNote?.event?.let { innerGift -> + eventProcessor.deleteEvent(innerGift, innerGiftNote) + } + } + } + + private suspend fun processNewGiftWrap( + event: GiftWrapEvent, + eventNote: Note, + publicNote: Note, + ) { + val innerGift = event.unwrapOrNull(account.signer) ?: return + + eventNote.event = event.copyNoContent() + if (cache.justConsume(innerGift, null, false)) { + cache.copyRelaysFromTo(publicNote, innerGift) + val innerGiftNote = cache.getOrCreateNote(innerGift.id) + eventProcessor.consumeEvent(innerGift, innerGiftNote, publicNote) + } + } + + private suspend fun processExistingGiftWrap( + innerGiftId: String, + publicNote: Note, + ) { + cache.copyRelaysFromTo(publicNote, innerGiftId) + val innerGiftNote = cache.getOrCreateNote(innerGiftId) + innerGiftNote.event?.let { innerGift -> + eventProcessor.consumeEvent(innerGift, innerGiftNote, publicNote) + } + } +} + +class SealedRumorEventHandler( + private val account: Account, + private val cache: LocalCache, + private val eventProcessor: EventProcessor, +) : EventHandler { + override suspend fun add( + event: SealedRumorEvent, + eventNote: Note, + publicNote: Note, + ) { + val rumorId = event.innerEventId + if (rumorId == null) { + processNewSealedRumor(event, eventNote, publicNote) + } else { + processExistingSealedRumor(rumorId, publicNote) + } + } + + override suspend fun delete( + event: SealedRumorEvent, + eventNote: Note, + ) { + event.innerEventId?.let { rumorId -> + val innerRumorNote = cache.getNoteIfExists(rumorId) + innerRumorNote?.event?.let { innerRumor -> + eventProcessor.deleteEvent(innerRumor, innerRumorNote) + } + } + } + + private suspend fun processNewSealedRumor( + event: SealedRumorEvent, + eventNote: Note, + publicNote: Note, + ) { + val innerRumor = event.unsealOrNull(account.signer) ?: return + + eventNote.event = event.copyNoContent() + cache.justConsume(innerRumor, null, true) + cache.copyRelaysFromTo(publicNote, innerRumor) + + val innerRumorNote = cache.getOrCreateNote(innerRumor.id) + eventProcessor.consumeEvent(innerRumor, innerRumorNote, publicNote) + } + + private suspend fun processExistingSealedRumor( + rumorId: String, + publicNote: Note, + ) { + cache.copyRelaysFromTo(publicNote, rumorId) + val innerRumorNote = cache.getOrCreateNote(rumorId) + innerRumorNote.event?.let { innerRumor -> + eventProcessor.consumeEvent(innerRumor, innerRumorNote, publicNote) + } + } +} + +class LnZapRequestEventHandler( + val decryptionCache: PrivateZapCache, +) : EventHandler { + override suspend fun add( + event: LnZapRequestEvent, + eventNote: Note, + publicNote: Note, + ) { + if (decryptionCache.cachedPrivateZap(event) == null && event.isPrivateZap()) { + decryptionCache.decryptPrivateZap(event) + } + } + + override suspend fun delete( + event: LnZapRequestEvent, + eventNote: Note, + ) { + if (event.isPrivateZap()) { + decryptionCache.delete(event) + } + } +} + +class LnZapEventHandler( + val decryptionCache: PrivateZapCache, +) : EventHandler { + override suspend fun delete( + event: LnZapEvent, + eventNote: Note, + ) { + event.zapRequest?.let { req -> + if (req.isPrivateZap()) { + decryptionCache.delete(req) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt deleted file mode 100644 index aa0a44e14e..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt +++ /dev/null @@ -1,171 +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.ui.screen.loggedIn - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Nav -import com.vitorpamplona.amethyst.ui.navigation.Route -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable -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.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent -import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent -import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent -import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -@Composable -fun LoadRedirectScreen( - eventId: String?, - accountViewModel: AccountViewModel, - nav: Nav, -) { - if (eventId == null) return - - var noteBase by remember { mutableStateOf(null) } - - LaunchedEffect(eventId) { - launch(Dispatchers.IO) { - val newNoteBase = LocalCache.checkGetOrCreateNote(eventId) - if (newNoteBase != noteBase) { - noteBase = newNoteBase - } - } - } - - noteBase?.let { - LoadRedirectScreen( - baseNote = it, - accountViewModel = accountViewModel, - nav = nav, - ) - } -} - -@Composable -fun LoadRedirectScreen( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: INav, -) { - val noteState by baseNote.live().metadata.observeAsState() - - LaunchedEffect(key1 = noteState) { - val note = noteState?.note ?: return@LaunchedEffect - val event = note.event - - if (event != null) { - withContext(Dispatchers.IO) { redirect(event, accountViewModel, nav) } - } - } - - Column( - Modifier.fillMaxHeight().fillMaxWidth().padding(horizontal = 50.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text(stringRes(R.string.looking_for_event, baseNote.idHex)) - } -} - -fun redirect( - eventId: HexKey, - accountViewModel: AccountViewModel, - nav: INav, -) { - LocalCache.getNoteIfExists(eventId)?.event?.let { - redirect(it, accountViewModel, nav) - } -} - -fun redirect( - event: Event, - accountViewModel: AccountViewModel, - nav: INav, -) { - val channelHex = - 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 - } - - if (event is GiftWrapEvent) { - event.innerEventId?.let { - redirect(it, accountViewModel, nav) - } ?: run { - accountViewModel.unwrap(event) { redirect(it, accountViewModel, nav) } - } - } else if (event is SealedRumorEvent) { - event.innerEventId?.let { - redirect(it, accountViewModel, nav) - } ?: run { - accountViewModel.unseal(event) { redirect(it, accountViewModel, nav) } - } - } else { - if (event is ChannelCreateEvent) { - nav.popUpTo(Route.Channel(event.id), Route.EventRedirect::class) - } else if (event is ChatroomKeyable) { - val withKey = event.chatroomKey(accountViewModel.userProfile().pubkeyHex) - accountViewModel.userProfile().createChatroom(withKey) - nav.popUpTo(Route.Room(withKey.hashCode()), Route.EventRedirect::class) - } else if (channelHex != null) { - nav.popUpTo(Route.Channel(channelHex), Route.EventRedirect::class) - } else { - nav.popUpTo(Route.Note(event.id), Route.EventRedirect::class) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt index 35b99b6eb6..b5e831e9bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt @@ -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,6 +21,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -30,30 +32,26 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LifecycleResumeEffect -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.AccountSettings -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils -import com.vitorpamplona.amethyst.ui.MainActivity -import com.vitorpamplona.amethyst.ui.components.getActivity +import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayAuthSubscription +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.navigation.AppNavigation -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel -import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus -import com.vitorpamplona.amethyst.ui.tor.TorType -import com.vitorpamplona.quartz.nip55AndroidSigner.NostrSignerExternal -import kotlinx.coroutines.CancellationException +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssemblerSubscription +import com.vitorpamplona.quartz.nip55AndroidSigner.client.IActivityLauncher import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.launch @Composable @@ -70,21 +68,40 @@ fun LoggedInPage( AccountViewModel.Factory( accountSettings, sharedPreferencesViewModel.sharedPrefs, + Amethyst.instance, ), ) - Log.d("ManageRelayServices", "LoggedInPage $accountViewModel") - accountViewModel.firstRoute = route - ManageSpamFilters(accountViewModel) + // Adds this account to the authentication procedures for relays. + RelayAuthSubscription(accountViewModel) - ManageRelayServices(accountViewModel, sharedPreferencesViewModel) + // Sets up Coil's Image Loader + ObserveImageLoadingTor(accountViewModel) - ManageTorInstance(accountViewModel) + // Sets up the use of Proxy based on this Account's settings + SetProxyDeterminator(accountViewModel) + // Loads account information + DMs and Notifications from Relays. + AccountFilterAssemblerSubscription(accountViewModel) + + // Pre-loads each of the main screens. + HomeFilterAssemblerSubscription(accountViewModel) + ChatroomListFilterAssemblerSubscription(accountViewModel) + VideoFilterAssemblerSubscription(accountViewModel) + DiscoveryFilterAssemblerSubscription(accountViewModel) + + // Updates local cache of the anti-spam filter choice of this user. + ObserveAntiSpamFilterSettings(accountViewModel) + + // Pauses relay services when the app pauses + ManageRelayServices(accountViewModel) + + // Listens to Amber ListenToExternalSignerIfNeeded(accountViewModel) + // Register token with the Push Notification Provider. NotificationRegistration(accountViewModel) AppNavigation( @@ -95,68 +112,35 @@ fun LoggedInPage( } @Composable -fun ManageSpamFilters(accountViewModel: AccountViewModel) { +fun ObserveAntiSpamFilterSettings(accountViewModel: AccountViewModel) { val isSpamActive by accountViewModel.account.settings.syncedSettings.security.filterSpamFromStrangers .collectAsStateWithLifecycle(true) - LocalCache.antiSpam.active = isSpamActive + Amethyst.instance.cache.antiSpam.active = isSpamActive } @Composable -fun ManageRelayServices( - accountViewModel: AccountViewModel, - sharedPreferencesViewModel: SharedPreferencesViewModel, -) { - LaunchedEffect( - sharedPreferencesViewModel.sharedPrefs.currentNetworkId, - sharedPreferencesViewModel.sharedPrefs.isOnMobileOrMeteredConnection, - ) { - Log.d("ManageRelayServices", "Loading/Change Network Id/State ${sharedPreferencesViewModel.sharedPrefs.currentNetworkId}, forcing start/restart of the relay services") - accountViewModel.forceRestartServices() +fun SetProxyDeterminator(accountViewModel: AccountViewModel) { + LaunchedEffect(accountViewModel) { + Amethyst.instance.torProxySettingsAnchor.flow + .tryEmit(accountViewModel.account.torRelayState.flow) } +} - val lifeCycleOwner = LocalLifecycleOwner.current - - val scope = rememberCoroutineScope() - var job = remember { null } - - Log.d("ManageRelayServices", "Job $job for $accountViewModel") - - DisposableEffect(key1 = accountViewModel) { - job?.cancel() - val observer = - LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_RESUME -> { - job?.cancel() - Log.d("ManageRelayServices", "Resuming Relay Services $accountViewModel") - job = accountViewModel.justStart() - } - Lifecycle.Event.ON_PAUSE -> { - Log.d("ManageRelayServices", "Prepare to pause Relay Services $accountViewModel") - job?.cancel() - job = - scope.launch { - delay(30000) // 30 seconds - Log.d("ManageRelayServices", "Pausing Relay Services $accountViewModel") - accountViewModel.justPause() - } - } - else -> {} - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { - job?.cancel() - lifeCycleOwner.lifecycle.removeObserver(observer) - Log.d("ManageRelayServices", "Disposing Relay Services $accountViewModel") - // immediately stops upon disposal - accountViewModel.pauseAndLogOff() - } +@Composable +fun ObserveImageLoadingTor(accountViewModel: AccountViewModel) { + LaunchedEffect(accountViewModel) { + Amethyst.instance.setImageLoader(accountViewModel.account.privacyState::shouldUseTorForImageDownload) } } +@Composable +fun ManageRelayServices(accountViewModel: AccountViewModel) { + val relayServices by Amethyst.instance.relayProxyClientConnector.relayServices + .collectAsStateWithLifecycle() + Log.d("ManageRelayServices", "Relay Services changed $relayServices") +} + @Composable fun NotificationRegistration(accountViewModel: AccountViewModel) { val scope = rememberCoroutineScope() @@ -167,7 +151,10 @@ fun NotificationRegistration(accountViewModel: AccountViewModel) { job?.cancel() job = scope.launch { - PushNotificationUtils.checkAndInit(LocalPreferences.allSavedAccounts(), accountViewModel::okHttpClientForTrustedRelays) + PushNotificationUtils.checkAndInit( + LocalPreferences.allSavedAccounts(), + accountViewModel::okHttpClientForTrustedRelays, + ) } onPauseOrDispose { @@ -176,96 +163,39 @@ fun NotificationRegistration(accountViewModel: AccountViewModel) { } } -@Composable -fun ManageTorInstance(accountViewModel: AccountViewModel) { - val torSettings by accountViewModel.account.settings.torSettings.torType - .collectAsStateWithLifecycle() - if (torSettings == TorType.INTERNAL) { - WatchTorConnection(accountViewModel) - } -} - -@Composable -fun WatchTorConnection(accountViewModel: AccountViewModel) { - val status by Amethyst.instance.torManager.status - .collectAsStateWithLifecycle() - - if (status is TorServiceStatus.Active) { - LaunchedEffect(key1 = status, key2 = accountViewModel) { - Log.d("TorService", "Tor has just finished connecting, force restart relays $accountViewModel") - accountViewModel.changeProxyPort((status as TorServiceStatus.Active).port) - } - } -} - @Composable private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) { - if (accountViewModel.account.signer is NostrSignerExternal) { - val activity = getActivity() as MainActivity - - val lifeCycleOwner = LocalLifecycleOwner.current + if (accountViewModel.account.signer is IActivityLauncher) { val launcher = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartActivityForResult(), onResult = { result -> - if (result.resultCode != Activity.RESULT_OK) { - accountViewModel.toastManager.toast( - R.string.sign_request_rejected, - R.string.sign_request_rejected_description, - ) - } else { + if (result.resultCode == Activity.RESULT_OK) { result.data?.let { accountViewModel.runOnIO { - accountViewModel.account.signer.launcher - .newResult(it) + accountViewModel.account.signer.newResponse(it) } } } }, ) - DisposableEffect(accountViewModel, accountViewModel.account, launcher, activity, lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - accountViewModel.account.signer.launcher.registerLauncher( - launcher = { - try { - launcher.launch(it) - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e("Signer", "Error opening Signer app", e) - accountViewModel.toastManager.toast( - R.string.error_opening_external_signer, - R.string.error_opening_external_signer_description, - ) - } - }, - contentResolver = Amethyst.instance::contentResolverFn, - ) - } + DisposableEffect(accountViewModel, accountViewModel.account, launcher) { + val launcher: (Intent) -> Unit = { intent -> + try { + launcher.launch(intent) + } catch (e: ActivityNotFoundException) { + accountViewModel.toastManager.toast( + R.string.error_opening_external_signer, + R.string.error_opening_external_signer_description, + ) + throw e } + } - lifeCycleOwner.lifecycle.addObserver(observer) - accountViewModel.account.signer.launcher.registerLauncher( - launcher = { - try { - launcher.launch(it) - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e("Signer", "Error opening Signer app", e) - accountViewModel.toastManager.toast( - R.string.error_opening_external_signer, - R.string.error_opening_external_signer_description, - ) - } - }, - contentResolver = Amethyst.instance::contentResolverFn, - ) + accountViewModel.account.signer.registerForegroundLauncher(launcher) onDispose { - accountViewModel.account.signer.launcher - .clearLauncher() - lifeCycleOwner.lifecycle.removeObserver(observer) + accountViewModel.account.signer.unregisterForegroundLauncher(launcher) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt deleted file mode 100644 index 985cf68a31..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt +++ /dev/null @@ -1,716 +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.ui.screen.loggedIn - -import android.content.Intent -import android.net.Uri -import android.os.Parcelable -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Alignment.Companion.CenterVertically -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.core.util.Consumer -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource -import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel -import com.vitorpamplona.amethyst.ui.actions.RelaySelectionDialog -import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType -import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery -import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia -import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton -import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji -import com.vitorpamplona.amethyst.ui.components.getActivity -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Nav -import com.vitorpamplona.amethyst.ui.note.BaseUserPicture -import com.vitorpamplona.amethyst.ui.note.CloseIcon -import com.vitorpamplona.amethyst.ui.note.NoteCompose -import com.vitorpamplona.amethyst.ui.note.PollIcon -import com.vitorpamplona.amethyst.ui.note.RegularPostIcon -import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer -import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList -import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton -import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest -import com.vitorpamplona.amethyst.ui.note.creators.location.AddGeoHashButton -import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash -import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField -import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewUrl -import com.vitorpamplona.amethyst.ui.note.creators.products.AddClassifiedsButton -import com.vitorpamplona.amethyst.ui.note.creators.products.SellProduct -import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton -import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest -import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription -import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList -import com.vitorpamplona.amethyst.ui.note.creators.zappolls.PollField -import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.AddZapraiserButton -import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.ZapRaiserRequest -import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapTo -import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapToButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.ButtonBorder -import com.vitorpamplona.amethyst.ui.theme.QuoteBorder -import com.vitorpamplona.amethyst.ui.theme.Size10dp -import com.vitorpamplona.amethyst.ui.theme.Size35dp -import com.vitorpamplona.amethyst.ui.theme.Size5dp -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer -import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink -import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.amethyst.ui.theme.replyModifier -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class) -@Composable -fun NewPostScreen( - message: String? = null, - attachment: Uri? = null, - baseReplyTo: Note? = null, - quote: Note? = null, - fork: Note? = null, - version: Note? = null, - draft: Note? = null, - enableGeolocation: Boolean = false, - accountViewModel: AccountViewModel, - nav: Nav, -) { - val postViewModel: NewPostViewModel = viewModel() - postViewModel.init(accountViewModel) - postViewModel.wantsToAddGeoHash = enableGeolocation - - val context = LocalContext.current - val activity = context.getActivity() - - val scrollState = rememberScrollState() - val scope = rememberCoroutineScope() - var showRelaysDialog by remember { mutableStateOf(false) } - var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() } - - LaunchedEffect(key1 = postViewModel.draftTag) { - launch(Dispatchers.IO) { - postViewModel.draftTextChanges - .receiveAsFlow() - .debounce(1000) - .collectLatest { - postViewModel.sendDraft(relayList = relayList) - } - } - } - - LaunchedEffect(Unit) { - launch(Dispatchers.IO) { - postViewModel.load(baseReplyTo, quote, fork, version, draft) - message?.ifBlank { null }?.let { - postViewModel.updateMessage(TextFieldValue(it)) - } - attachment?.let { - val mediaType = context.contentResolver.getType(it) - postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) - } - } - } - - DisposableEffect(Unit) { - NostrSearchEventOrUserDataSource.start() - - onDispose { - NostrSearchEventOrUserDataSource.clear() - NostrSearchEventOrUserDataSource.stop() - } - } - - DisposableEffect(nav, activity) { - // Microsoft's swift key sends Gifs as new actions - - val consumer = - Consumer { intent -> - if (intent.action == Intent.ACTION_SEND) { - intent.getStringExtra(Intent.EXTRA_TEXT)?.ifBlank { null }?.let { - postViewModel.addToMessage(it) - } - - (intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri)?.let { - val mediaType = context.contentResolver.getType(it) - postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) - } - } - } - - activity.addOnNewIntentListener(consumer) - onDispose { activity.removeOnNewIntentListener(consumer) } - } - - WatchAndLoadMyEmojiList(accountViewModel) - - Scaffold( - topBar = { - TopAppBar( - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer(modifier = StdHorzSpacer) - - Box { - IconButton( - modifier = Modifier.align(Alignment.Center), - onClick = { showRelaysDialog = true }, - ) { - Icon( - painter = painterResource(R.drawable.relays), - contentDescription = stringRes(id = R.string.relay_list_selector), - modifier = Modifier.height(25.dp), - tint = MaterialTheme.colorScheme.onBackground, - ) - } - } - PostButton( - onPost = { - postViewModel.sendPost(relayList = relayList) - scope.launch { - delay(100) - nav.popBack() - } - }, - isActive = postViewModel.canPost(), - ) - } - }, - navigationIcon = { - Row { - Spacer(modifier = StdHorzSpacer) - CloseButton( - onPress = { - scope.launch { - withContext(Dispatchers.IO) { - postViewModel.sendDraftSync(relayList = relayList) - postViewModel.cancel() - } - delay(100) - nav.popBack() - } - }, - ) - } - }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - ) - }, - ) { pad -> - if (showRelaysDialog) { - RelaySelectionDialog( - preSelectedList = relayList, - onClose = { showRelaysDialog = false }, - onPost = { relayList = it }, - accountViewModel = accountViewModel, - nav = nav, - ) - } - Surface( - modifier = - Modifier - .padding(pad) - .consumeWindowInsets(pad) - .imePadding(), - ) { - Column( - modifier = - Modifier.fillMaxSize(), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding( - start = Size10dp, - end = Size10dp, - ).weight(1f), - ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .verticalScroll(scrollState), - ) { - postViewModel.originalNote?.let { - Row { - NoteCompose( - baseNote = it, - modifier = MaterialTheme.colorScheme.replyModifier, - isQuotedNote = true, - unPackReply = false, - makeItShort = true, - quotesLeft = 1, - accountViewModel = accountViewModel, - nav = nav, - ) - Spacer(modifier = StdVertSpacer) - } - } - - Row { - Notifying(postViewModel.pTags?.toImmutableList()) { - postViewModel.removeFromReplyList(it) - } - } - - if (postViewModel.wantsProduct) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), - ) { - SellProduct(postViewModel = postViewModel) - } - } - - Row( - modifier = Modifier.padding(vertical = Size10dp), - ) { - BaseUserPicture( - accountViewModel.userProfile(), - Size35dp, - accountViewModel = accountViewModel, - ) - MessageField( - if (postViewModel.wantsProduct) R.string.description else R.string.what_s_on_your_mind, - postViewModel, - ) - } - - if (postViewModel.wantsPoll) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), - ) { - PollField(postViewModel) - } - } - - DisplayPreviews(postViewModel, accountViewModel, nav) - - if (postViewModel.wantsToMarkAsSensitive) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), - ) { - ContentSensitivityExplainer() - } - } - - if (postViewModel.wantsToAddGeoHash) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), - ) { - LocationAsHash(postViewModel) { - SettingsRow( - R.string.geohash_exclusive, - R.string.geohash_exclusive_explainer, - ) { - Switch(postViewModel.wantsExclusiveGeoPost, onCheckedChange = { postViewModel.wantsExclusiveGeoPost = it }) - } - } - } - } - - if (postViewModel.wantsForwardZapTo) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(top = Size5dp, bottom = Size5dp, start = Size10dp), - ) { - ForwardZapTo(postViewModel, accountViewModel) - } - } - - postViewModel.multiOrchestrator?.let { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), - ) { - ImageVideoDescription( - it, - accountViewModel.account.settings.defaultFileServer, - onAdd = { alt, server, sensitiveContent, mediaQuality -> - postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, false, server, accountViewModel.toastManager::toast, context) - if (server.type != ServerType.NIP95) { - accountViewModel.account.settings.changeDefaultFileServer(server) - } - }, - onDelete = postViewModel::deleteMediaToUpload, - onCancel = { postViewModel.multiOrchestrator = null }, - accountViewModel = accountViewModel, - ) - } - } - - if (postViewModel.wantsInvoice) { - postViewModel.lnAddress()?.let { lud16 -> - InvoiceRequest( - lud16, - accountViewModel.account.userProfile().pubkeyHex, - accountViewModel, - stringRes(id = R.string.lightning_invoice), - stringRes(id = R.string.lightning_create_and_add_invoice), - onSuccess = { - postViewModel.insertAtCursor(it) - postViewModel.wantsInvoice = false - }, - onError = { title, message -> accountViewModel.toastManager.toast(title, message) }, - ) - } - } - - if (postViewModel.wantsSecretEmoji) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), - ) { - Column(Modifier.fillMaxWidth()) { - SecretEmojiRequest { - postViewModel.insertAtCursor(it) - postViewModel.wantsSecretEmoji = false - } - } - } - } - - if (postViewModel.wantsZapraiser && postViewModel.hasLnAddress()) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), - ) { - ZapRaiserRequest( - stringRes(id = R.string.zapraiser), - postViewModel, - ) - } - } - } - } - - postViewModel.userSuggestions?.let { - ShowUserSuggestionList( - it, - postViewModel::autocompleteWithUser, - accountViewModel, - modifier = Modifier.heightIn(0.dp, 300.dp), - ) - } - - postViewModel.emojiSuggestions?.let { - ShowEmojiSuggestionList( - it, - postViewModel::autocompleteWithEmoji, - postViewModel::autocompleteWithEmojiUrl, - accountViewModel, - modifier = Modifier.heightIn(0.dp, 300.dp), - ) - } - - BottomRowActions(postViewModel) - } - } - } -} - -@Composable -private fun BottomRowActions(postViewModel: NewPostViewModel) { - val scrollState = rememberScrollState() - Row( - modifier = - Modifier - .horizontalScroll(scrollState) - .fillMaxWidth() - .height(50.dp), - verticalAlignment = CenterVertically, - ) { - SelectFromGallery( - isUploading = postViewModel.isUploadingImage, - tint = MaterialTheme.colorScheme.onBackground, - modifier = Modifier, - ) { - postViewModel.selectImage(it) - } - - TakePictureButton( - onPictureTaken = { - postViewModel.selectImage(it) - }, - ) - - if (postViewModel.canUsePoll) { - // These should be hashtag recommendations the user selects in the future. - // val hashtag = stringRes(R.string.poll_hashtag) - // postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag) - AddPollButton(postViewModel.wantsPoll) { - postViewModel.wantsPoll = !postViewModel.wantsPoll - if (postViewModel.wantsPoll) { - postViewModel.wantsProduct = false - } - } - } - - AddClassifiedsButton(postViewModel.wantsProduct) { - postViewModel.wantsProduct = !postViewModel.wantsProduct - if (postViewModel.wantsProduct) { - postViewModel.wantsPoll = false - } - } - - ForwardZapToButton(postViewModel.wantsForwardZapTo) { - postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo - } - - if (postViewModel.canAddZapRaiser) { - AddZapraiserButton(postViewModel.wantsZapraiser) { - postViewModel.wantsZapraiser = !postViewModel.wantsZapraiser - } - } - - MarkAsSensitiveButton(postViewModel.wantsToMarkAsSensitive) { - postViewModel.toggleMarkAsSensitive() - } - - AddGeoHashButton(postViewModel.wantsToAddGeoHash) { - postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash - } - - AddSecretEmojiButton(postViewModel.wantsSecretEmoji) { - postViewModel.wantsSecretEmoji = !postViewModel.wantsSecretEmoji - } - - if (postViewModel.canAddInvoice && postViewModel.hasLnAddress()) { - AddLnInvoiceButton(postViewModel.wantsInvoice) { - postViewModel.wantsInvoice = !postViewModel.wantsInvoice - } - } - } -} - -@Composable -fun DisplayPreviews( - postViewModel: NewPostViewModel, - accountViewModel: AccountViewModel, - nav: INav, -) { - val urlPreviews by postViewModel.urlPreviews.results.collectAsStateWithLifecycle(emptyList()) - - if (urlPreviews.isNotEmpty()) { - Row(modifier = Modifier.padding(vertical = Size5dp)) { - LazyRow(modifier = Modifier.height(100.dp)) { - items(urlPreviews) { - Box(modifier = Modifier.aspectRatio(1f).clip(shape = QuoteBorder)) { - PreviewUrl(it, accountViewModel, nav) - } - } - } - } - } -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun Notifying( - baseMentions: ImmutableList?, - onClick: (User) -> Unit, -) { - val mentions = baseMentions?.toSet() - - FlowRow(horizontalArrangement = Arrangement.spacedBy(5.dp)) { - if (!mentions.isNullOrEmpty()) { - Text( - stringRes(R.string.reply_notify), - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.align(CenterVertically), - ) - - mentions.forEachIndexed { idx, user -> - val innerUserState by user.live().metadata.observeAsState() - innerUserState?.user?.let { myUser -> - val tags = myUser.info?.tags - - Button( - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.mediumImportanceLink, - ), - onClick = { onClick(myUser) }, - ) { - CreateTextWithEmoji( - text = remember(innerUserState) { "✖ ${myUser.toBestDisplayName()}" }, - tags = tags, - color = Color.White, - textAlign = TextAlign.Center, - ) - } - } - } - } - } -} - -@Composable -private fun AddPollButton( - isPollActive: Boolean, - onClick: () -> Unit, -) { - IconButton( - onClick = { onClick() }, - ) { - if (!isPollActive) { - PollIcon() - } else { - RegularPostIcon() - } - } -} - -@Composable -fun CloseButton( - onPress: () -> Unit, - modifier: Modifier = Modifier, -) { - OutlinedButton( - onClick = onPress, - modifier = modifier, - contentPadding = PaddingValues(horizontal = Size5dp), - ) { - CloseIcon() - } -} - -@Composable -fun PostButton( - onPost: () -> Unit = {}, - isActive: Boolean, - modifier: Modifier = Modifier, -) { - Button( - modifier = modifier, - enabled = isActive, - onClick = onPost, - ) { - Text(text = stringRes(R.string.post)) - } -} - -@Composable -fun SaveButton( - onPost: () -> Unit = {}, - isActive: Boolean, - modifier: Modifier = Modifier, -) { - Button( - enabled = isActive, - modifier = modifier, - onClick = onPost, - ) { - Text(text = stringRes(R.string.save)) - } -} - -@Composable -fun CreateButton( - onPost: () -> Unit = {}, - isActive: Boolean, - modifier: Modifier = Modifier, -) { - Button( - enabled = isActive, - modifier = modifier, - onClick = onPost, - ) { - Text(text = stringRes(R.string.create)) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/BookmarkListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/BookmarkListScreen.kt index 27cc5ce63d..ed958beadb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/BookmarkListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/BookmarkListScreen.kt @@ -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 @@ -33,19 +33,19 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.TopBarWithBackButton -import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPrivateFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPublicFeedViewModel +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.dal.BookmarkPrivateFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.dal.BookmarkPublicFeedViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.TabRowHeight import kotlinx.coroutines.launch @@ -55,35 +55,36 @@ fun BookmarkListScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val publicFeedViewModel: NostrBookmarkPublicFeedViewModel = + val publicFeedViewModel: BookmarkPublicFeedViewModel = viewModel( key = "NostrBookmarkPublicFeedViewModel", - factory = NostrBookmarkPublicFeedViewModel.Factory(accountViewModel.account), + factory = BookmarkPublicFeedViewModel.Factory(accountViewModel.account), ) - val privateFeedViewModel: NostrBookmarkPrivateFeedViewModel = + val privateFeedViewModel: BookmarkPrivateFeedViewModel = viewModel( key = "NostrBookmarkPrivateFeedViewModel", - factory = NostrBookmarkPrivateFeedViewModel.Factory(accountViewModel.account), + factory = BookmarkPrivateFeedViewModel.Factory(accountViewModel.account), ) - val userState by accountViewModel.account.decryptBookmarks.observeAsState() + val bookmarkState by accountViewModel.account.bookmarkState.bookmarks + .collectAsStateWithLifecycle(null) - LaunchedEffect(userState) { + LaunchedEffect(bookmarkState) { publicFeedViewModel.invalidateData() privateFeedViewModel.invalidateData() } - RenderBookmarkScreen(privateFeedViewModel, accountViewModel, nav, publicFeedViewModel) + RenderBookmarkScreen(publicFeedViewModel, privateFeedViewModel, accountViewModel, nav) } @Composable @OptIn(ExperimentalFoundationApi::class) private fun RenderBookmarkScreen( - privateFeedViewModel: NostrBookmarkPrivateFeedViewModel, + publicFeedViewModel: BookmarkPublicFeedViewModel, + privateFeedViewModel: BookmarkPrivateFeedViewModel, accountViewModel: AccountViewModel, nav: INav, - publicFeedViewModel: NostrBookmarkPublicFeedViewModel, ) { val pagerState = rememberPagerState { 2 } val coroutineScope = rememberCoroutineScope() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPrivateFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPrivateFeedFilter.kt new file mode 100644 index 0000000000..85cdc1d776 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPrivateFeedFilter.kt @@ -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.ui.screen.loggedIn.bookmarks.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.FeedFilter + +class BookmarkPrivateFeedFilter( + val account: Account, +) : FeedFilter() { + override fun feedKey(): String = + account.bookmarkState.bookmarks.value + .hashCode() + .toString() + + override fun feed(): List = account.bookmarkState.bookmarks.value.private +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPrivateFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPrivateFeedViewModel.kt new file mode 100644 index 0000000000..3ddbd8e090 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPrivateFeedViewModel.kt @@ -0,0 +1,39 @@ +/** + * 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.ui.screen.loggedIn.bookmarks.dal + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.FeedViewModel + +@Stable +class BookmarkPrivateFeedViewModel( + val account: Account, +) : FeedViewModel(BookmarkPrivateFeedFilter(account)) { + class Factory( + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = BookmarkPrivateFeedViewModel(account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPublicFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPublicFeedFilter.kt new file mode 100644 index 0000000000..34a3962fb3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPublicFeedFilter.kt @@ -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.ui.screen.loggedIn.bookmarks.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.FeedFilter + +class BookmarkPublicFeedFilter( + val account: Account, +) : FeedFilter() { + override fun feedKey(): String = + account.bookmarkState.bookmarks.value + .hashCode() + .toString() + + override fun feed(): List = account.bookmarkState.bookmarks.value.public +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPublicFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPublicFeedViewModel.kt new file mode 100644 index 0000000000..fb8ec6c8e6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPublicFeedViewModel.kt @@ -0,0 +1,39 @@ +/** + * 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.ui.screen.loggedIn.bookmarks.dal + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.FeedViewModel + +@Stable +class BookmarkPublicFeedViewModel( + val account: Account, +) : FeedViewModel(BookmarkPublicFeedFilter(account)) { + class Factory( + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = BookmarkPublicFeedViewModel(account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt index b8ac4e5bf8..b27f5ed3de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt @@ -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 @@ -37,7 +37,7 @@ import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.FeedState import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState import com.vitorpamplona.amethyst.ui.screen.FeedViewModel import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index 6ba0a17f86..b92c80cf40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -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 @@ -44,9 +44,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.DisplayDraftChat import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.NoteQuickActionMenu @@ -75,7 +75,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size18Modifier import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash +import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip13Pow.strongPoWOrNull import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable @@ -170,7 +170,7 @@ fun NormalChatNote( parentBackgroundColor = parentBackgroundColor, onClick = { if (note.event is ChannelCreateEvent) { - nav.nav(Route.Channel(note.idHex)) + nav.nav(Route.PublicChatChannel(note.idHex)) true } else { false @@ -224,10 +224,10 @@ fun NormalChatNote( ZapReaction(note, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav = nav) - val geo = remember(note) { note.event?.getGeoHash() } + val geo = remember(note) { note.event?.geoHashOrScope() } if (geo != null) { Spacer(StdHorzSpacer) - DisplayLocation(geo, nav) + DisplayLocation(geo, accountViewModel, nav) } val pow = remember(note) { note.event?.strongPoWOrNull() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt index 7e25e709eb..edef823138 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt index 0b998c7c1e..64352fb5e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt @@ -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,13 +22,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.FollowingIcon import com.vitorpamplona.amethyst.ui.note.InnerUserPicture import com.vitorpamplona.amethyst.ui.note.WatchUserFollows @@ -55,7 +55,7 @@ private fun WatchAndDisplayUser( accountViewModel: AccountViewModel, nav: INav, ) { - val userState by author.live().userMetadataInfo.observeAsState() + val userState by observeUserInfo(author, accountViewModel) UserDisplayNameLayout( picture = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/NewDateOrSubjectDivisor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/NewDateOrSubjectDivisor.kt index f5d3549bc2..b7c23d1e77 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/NewDateOrSubjectDivisor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/NewDateOrSubjectDivisor.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt index fcc4cd2965..e58ab7c674 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatDivisor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatDivisor.kt index 70458cbdb2..7d558acdac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatDivisor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatDivisor.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/UserDisplayNameLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/UserDisplayNameLayout.kt index 2e506a9ffe..f404d27e96 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/UserDisplayNameLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/UserDisplayNameLayout.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChangeChannelMetadataNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChangeChannelMetadataNote.kt index fa786c9cd1..73753a70b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChangeChannelMetadataNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChangeChannelMetadataNote.kt @@ -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,7 +25,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import com.vitorpamplona.amethyst.model.Note -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.toImmutableListOfLists import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt index 4cd262587d..dd7f65e173 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt @@ -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 @@ -36,9 +36,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -50,18 +48,18 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Constants import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.Nip11CachedRetriever -import com.vitorpamplona.amethyst.service.Nip11Retriever import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationDialog import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter import com.vitorpamplona.amethyst.ui.theme.Size20dp @@ -69,14 +67,14 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.ui.theme.largeProfilePictureModifier -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent -import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelData -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter +import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelDataNorm @Composable fun RenderCreateChannelNote( @@ -111,11 +109,11 @@ fun RenderChannelDataPreview() { id = "bbaacc", uri = "nostr:nevent1...", channelInfo = - ChannelData( + ChannelDataNorm( "My Group", "Testing About me", "http://test.com", - listOf("wss://nostr.mom", "wss://nos.lol"), + listOf(Constants.mom, Constants.nos), ), tags = EmptyTagList, bgColor = remember { mutableStateOf(Color.Transparent) }, @@ -129,7 +127,7 @@ fun RenderChannelDataPreview() { fun RenderChannelData( id: HexKey, uri: String, - channelInfo: ChannelData, + channelInfo: ChannelDataNorm, tags: ImmutableListOfLists, bgColor: MutableState, accountViewModel: AccountViewModel, @@ -234,84 +232,27 @@ fun RenderRelayLinePreview() { @OptIn(ExperimentalFoundationApi::class) @Composable fun RenderRelayLinePublicChat( - dirtyUrl: String, + relay: NormalizedRelayUrl, accountViewModel: AccountViewModel, nav: INav, ) { @Suppress("ProduceStateDoesNotAssignValue") - val relayInfo by produceState( - initialValue = Nip11CachedRetriever.getFromCache(dirtyUrl), - ) { - if (value == null) { - accountViewModel.retrieveRelayDocument( - dirtyUrl, - onInfo = { - value = it - }, - onError = { url, errorCode, exceptionMessage -> - }, - ) - } - } - - var openRelayDialog by remember { mutableStateOf(false) } - - val info = - remember(dirtyUrl) { - RelayBriefInfoCache.get(RelayUrlFormatter.normalize(dirtyUrl)) - } - - if (openRelayDialog && relayInfo != null) { - RelayInformationDialog( - onClose = { openRelayDialog = false }, - relayInfo = relayInfo!!, - relayBriefInfo = info, - accountViewModel = accountViewModel, - nav = nav, - ) - } + val relayInfo by loadRelayInfo(relay, accountViewModel) val clipboardManager = LocalClipboardManager.current val clickableModifier = - remember(dirtyUrl) { + remember(relay) { Modifier.combinedClickable( onLongClick = { - clipboardManager.setText(AnnotatedString(dirtyUrl)) - }, - onClick = { - accountViewModel.retrieveRelayDocument( - dirtyUrl, - onInfo = { - openRelayDialog = true - }, - onError = { url, errorCode, exceptionMessage -> - accountViewModel.toastManager.toast( - R.string.unable_to_download_relay_document, - when (errorCode) { - Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> - R.string.relay_information_document_error_failed_to_assemble_url - - Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> - R.string.relay_information_document_error_failed_to_reach_server - - Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> - R.string.relay_information_document_error_failed_to_parse_response - - Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> - R.string.relay_information_document_error_failed_with_http - }, - url, - exceptionMessage ?: errorCode.toString(), - ) - }, - ) + clipboardManager.setText(AnnotatedString(relay.url)) }, + onClick = { nav.nav(Route.RelayInfo(relay.url)) }, ) } RenderRelayLine( - info.displayUrl, - relayInfo?.icon ?: info.favIcon, + relay.displayUrl(), + relayInfo.icon, clickableModifier, showPicture = accountViewModel.settings.showProfilePictures.value, loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderDraftEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderDraftEvent.kt index 1e4dc8b7bb..99a5d2031d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderDraftEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderDraftEvent.kt @@ -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,7 +25,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.graphics.Color import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.ObserveDraftEvent import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.NoteRow diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEncryptedFile.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEncryptedFile.kt index fc0de62cae..188edfcaef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEncryptedFile.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEncryptedFile.kt @@ -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 @@ -37,7 +37,7 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableContentView -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.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt index e39fd1e930..5bd75f486a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt @@ -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 @@ -29,7 +29,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContentOrNull import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomByAuthorScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomByAuthorScreen.kt index c189b9d519..e29488fd20 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomByAuthorScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomByAuthorScreen.kt @@ -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 @@ -28,9 +28,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RenderRoomTopBar import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt index 7b6e3173fb..28a4156c42 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt @@ -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,79 +21,37 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RenderRoomTopBar -import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch @Composable fun ChatroomScreen( - roomId: String?, + roomId: ChatroomKey, draftMessage: String? = null, replyToNote: HexKey? = null, editFromDraft: HexKey? = null, accountViewModel: AccountViewModel, nav: INav, ) { - if (roomId == null) return - DisappearingScaffold( isInvertedLayout = true, topBar = { - RoomTopBar(roomId, accountViewModel, nav) + RenderRoomTopBar(roomId, accountViewModel, nav) }, accountViewModel = accountViewModel, ) { Column(Modifier.padding(it)) { - Chatroom(roomId, draftMessage, replyToNote, editFromDraft, accountViewModel, nav) - } - } -} - -@Composable -private fun RoomTopBar( - id: String, - accountViewModel: AccountViewModel, - nav: INav, -) { - LoadRoom(roomId = id, accountViewModel) { room -> - if (room != null) { - RenderRoomTopBar(room, accountViewModel, nav) - } else { - Spacer(BottomTopHeight) - } - } -} - -@Composable -fun Chatroom( - roomId: String?, - draftMessage: String? = null, - replyToNote: HexKey? = null, - editFromDraft: HexKey? = null, - accountViewModel: AccountViewModel, - nav: INav, -) { - if (roomId == null) return - - LoadRoom(roomId, accountViewModel) { - it?.let { ChatroomView( - room = it, + room = roomId, draftMessage = draftMessage, replyToNote = replyToNote, editFromDraft = editFromDraft, @@ -103,28 +61,3 @@ fun Chatroom( } } } - -@Composable -fun LoadRoom( - roomId: String, - accountViewModel: AccountViewModel, - content: @Composable (ChatroomKey?) -> Unit, -) { - var room by remember(roomId) { mutableStateOf(null) } - - if (room == null) { - LaunchedEffect(key1 = roomId) { - launch(Dispatchers.IO) { - val newRoom = - accountViewModel.userProfile().privateChatrooms.keys.firstOrNull { - it.hashCode().toString() == roomId - } - if (room != newRoom) { - room = newRoom - } - } - } - } - - content(room) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt index cf0f03f5b5..a5f198c9ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt @@ -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,23 +25,20 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.amethyst.service.NostrChatroomDataSource -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMs import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMsAndDisplayIfNotFound -import com.vitorpamplona.amethyst.ui.screen.NostrChatroomFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal.ChatroomFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.ChatNewMessageViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.PrivateMessageEditFieldRow import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer @@ -58,11 +55,11 @@ fun ChatroomView( accountViewModel: AccountViewModel, nav: INav, ) { - val feedViewModel: NostrChatroomFeedViewModel = + val feedViewModel: ChatroomFeedViewModel = viewModel( key = room.hashCode().toString() + "ChatroomViewModels", factory = - NostrChatroomFeedViewModel.Factory( + ChatroomFeedViewModel.Factory( room, accountViewModel.account, ), @@ -120,40 +117,13 @@ fun ChatroomView( @Composable fun ChatroomViewUI( room: ChatroomKey, - feedViewModel: NostrChatroomFeedViewModel, + feedViewModel: ChatroomFeedViewModel, newPostModel: ChatNewMessageViewModel, accountViewModel: AccountViewModel, nav: INav, ) { - NostrChatroomDataSource.loadMessagesBetween(accountViewModel.account, room) - - val lifeCycleOwner = LocalLifecycleOwner.current - - DisposableEffect(room, accountViewModel) { - NostrChatroomDataSource.loadMessagesBetween(accountViewModel.account, room) - NostrChatroomDataSource.start() - feedViewModel.invalidateData() - - onDispose { NostrChatroomDataSource.stop() } - } - - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Private Message Start") - NostrChatroomDataSource.start() - feedViewModel.invalidateData() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Private Message Stop") - NostrChatroomDataSource.stop() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } + WatchLifecycleAndUpdateModel(feedViewModel) + ChatroomFilterAssemblerSubscription(room, accountViewModel.dataSources().chatroom, accountViewModel) Column(Modifier.fillMaxHeight()) { ObserveRelayListForDMsAndDisplayIfNotFound(accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/IncognitoBadge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/IncognitoBadge.kt index f983922957..a7f7444e8f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/IncognitoBadge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/IncognitoBadge.kt @@ -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,11 +24,11 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.ui.res.painterResource import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.painterRes +import com.vitorpamplona.amethyst.ui.theme.IncognitoIconModifier import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.incognitoIconModifier import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group @@ -37,17 +37,17 @@ import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group fun IncognitoBadge(baseNote: Note) { if (baseNote.event is NIP17Group) { Icon( - painter = painterResource(id = R.drawable.incognito), + painter = painterRes(resourceId = R.drawable.incognito, 1), null, - modifier = incognitoIconModifier, + modifier = IncognitoIconModifier, tint = MaterialTheme.colorScheme.placeholderText, ) Spacer(modifier = StdHorzSpacer) } else if (baseNote.event is PrivateDmEvent) { Icon( - painter = painterResource(id = R.drawable.incognito_off), + painter = painterRes(resourceId = R.drawable.incognito_off, 1), null, - modifier = incognitoIconModifier, + modifier = IncognitoIconModifier, tint = MaterialTheme.colorScheme.placeholderText, ) Spacer(modifier = StdHorzSpacer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedFilter.kt similarity index 77% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedFilter.kt index b1aa0119e0..3f24867d4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedFilter.kt @@ -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,10 +18,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey class ChatroomFeedFilter( @@ -32,18 +34,18 @@ class ChatroomFeedFilter( override fun feedKey(): String = withUser.hashCode().toString() override fun feed(): List { - val messages = account.userProfile().privateChatrooms[withUser] ?: return emptyList() + val chatroom = account.chatroomList.getOrCreatePrivateChatroom(withUser) - return messages.roomMessages + return chatroom.messages .filter { account.isAcceptable(it) } .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) .reversed() } override fun applyFilter(collection: Set): Set { - val messages = account.userProfile().privateChatrooms[withUser] ?: return emptySet() + val chatroom = account.chatroomList.getOrCreatePrivateChatroom(withUser) - return collection.filter { it in messages.roomMessages && account.isAcceptable(it) }.toSet() + return collection.filter { it in chatroom.messages && account.isAcceptable(it) }.toSet() } override fun sort(collection: Set): List = collection.sortedWith(DefaultFeedOrder) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedViewModel.kt new file mode 100644 index 0000000000..cf158e2664 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedViewModel.kt @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.FeedViewModel +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey + +class ChatroomFeedViewModel( + val user: ChatroomKey, + val account: Account, +) : FeedViewModel(ChatroomFeedFilter(user, account)) { + class Factory( + val user: ChatroomKey, + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = ChatroomFeedViewModel(user, account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt new file mode 100644 index 0000000000..740d4cddcd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt @@ -0,0 +1,51 @@ +/** + * 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.ui.screen.loggedIn.chats.privateDM.datasource + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey + +// This allows multiple screen to be listening to tags, even the same tag +class ChatroomQueryState( + val room: ChatroomKey, + val account: Account, +) { + val listId = room.hashCode().toString() +} + +class ChatroomFilterAssembler( + client: NostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + ChatroomFilterSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } + + override fun printStats() = group.forEach { it.printStats() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..bb98b4d517 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssemblerSubscription.kt @@ -0,0 +1,43 @@ +/** + * 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.ui.screen.loggedIn.chats.privateDM.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey + +@Composable +fun ChatroomFilterAssemblerSubscription( + room: ChatroomKey, + dataSource: ChatroomFilterAssembler, + accountViewModel: AccountViewModel, +) { + // different screens get different states + // even if they are tracking the same tag. + val state = + remember(accountViewModel.account) { + ChatroomQueryState(room, accountViewModel.account) + } + + KeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt new file mode 100644 index 0000000000..bfa91e4d8b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt @@ -0,0 +1,39 @@ +/** + * 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.ui.screen.loggedIn.chats.privateDM.datasource + +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient + +class ChatroomFilterSubAssembler( + client: NostrClient, + allKeys: () -> Set, +) : PerUserAndFollowListEoseManager(client, allKeys) { + override fun updateFilter( + key: ChatroomQueryState, + since: SincePerRelayMap?, + ) = filterNip04DMs(key.room.users, key.account, since) + + override fun user(key: ChatroomQueryState) = key.account.userProfile() + + override fun list(key: ChatroomQueryState) = key.listId +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt new file mode 100644 index 0000000000..7fac8d0bc4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt @@ -0,0 +1,92 @@ +/** + * 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.ui.screen.loggedIn.chats.privateDM.datasource + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent + +fun filterNip04DMs( + group: Set?, + account: Account?, + since: SincePerRelayMap?, +): List? { + if (group == null || group.isEmpty() || account == null) return null + + val userOutboxRelays = account.outboxRelays.flow.value + val userInboxRelays = account.dmRelays.flow.value + + val groupOutboxRelays = mutableSetOf() + val groupInboxRelays = mutableSetOf() + + group.forEach { + val authorHomeRelayEventAddress = AdvertisedRelayListEvent.createAddressTag(it) + val authorHomeRelayEvent = (LocalCache.getAddressableNoteIfExists(authorHomeRelayEventAddress)?.event as? AdvertisedRelayListEvent) + + val outbox = + authorHomeRelayEvent?.writeRelaysNorm()?.ifEmpty { null } + ?: LocalCache.relayHints.hintsForKey(it).ifEmpty { null } + ?: listOfNotNull(LocalCache.getUserIfExists(it)?.latestMetadataRelay) + + groupOutboxRelays.addAll(outbox) + + val inbox = + authorHomeRelayEvent?.readRelaysNorm()?.ifEmpty { null } + ?: LocalCache.relayHints.hintsForKey(it).ifEmpty { null } + ?: listOfNotNull(LocalCache.getUserIfExists(it)?.latestMetadataRelay) + + groupInboxRelays.addAll(inbox) + } + + val toMeRelays = (userInboxRelays + groupOutboxRelays) + val fromMeRelays = (userOutboxRelays + groupInboxRelays) + + return toMeRelays.map { + RelayBasedFilter( + relay = it, + filter = + Filter( + kinds = listOf(PrivateDmEvent.KIND), + authors = group.toList(), + tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), + since = since?.get(it)?.time, + ), + ) + } + + fromMeRelays.map { + RelayBasedFilter( + relay = it, + filter = + Filter( + kinds = listOf(PrivateDmEvent.KIND), + authors = listOf(account.userProfile().pubkeyHex), + tags = mapOf("p" to group.toList()), + since = since?.get(it)?.time, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/ChatroomHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/ChatroomHeader.kt index 56698cb975..8f062f1b1f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/ChatroomHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/ChatroomHeader.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt index 70b2d3e2df..15f12dc00a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt @@ -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 @@ -48,16 +48,14 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton +import com.vitorpamplona.amethyst.ui.note.buttons.PostButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.PostButton import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip17Dm.messages.changeSubject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch @Composable fun NewChatroomSubjectDialog( @@ -75,7 +73,12 @@ fun NewChatroomSubjectDialog( Surface { val groupName = remember { - mutableStateOf(accountViewModel.userProfile().privateChatrooms[room]?.subject ?: "") + mutableStateOf( + accountViewModel.account.chatroomList.rooms + .get(room) + ?.subject + ?.value ?: "", + ) } val message = remember { mutableStateOf("") } val scope = rememberCoroutineScope() @@ -95,7 +98,7 @@ fun NewChatroomSubjectDialog( PostButton( onPost = { - scope.launch(Dispatchers.IO) { + accountViewModel.runIOCatching { val template = ChatMessageEvent.build( message.value, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt index c6236e5ee5..d412f75c12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt @@ -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 @@ -47,8 +47,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures import com.vitorpamplona.amethyst.ui.note.UserCompose diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RoomNameOnlyDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RoomNameOnlyDisplay.kt index 7b971502dd..e5f0a008f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RoomNameOnlyDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RoomNameOnlyDisplay.kt @@ -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,14 +24,13 @@ import androidx.compose.foundation.layout.Row import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow -import androidx.lifecycle.distinctUntilChanged -import androidx.lifecycle.map +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserShortName import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.note.UsernameDisplay @@ -48,14 +47,11 @@ fun RoomNameOnlyDisplay( fontWeight: FontWeight = FontWeight.Bold, accountViewModel: AccountViewModel, ) { - val roomSubject by - accountViewModel - .userProfile() - .live() - .messages - .map { it.user.privateChatrooms[room]?.subject } - .distinctUntilChanged() - .observeAsState(accountViewModel.userProfile().privateChatrooms[room]?.subject) + // Subscribe in the LocalCache for changes that arrive in the device + val roomSubject by accountViewModel.account.chatroomList + .getOrCreatePrivateChatroom(room) + .subject + .collectAsStateWithLifecycle() CrossfadeIfEnabled(targetState = roomSubject, modifier, accountViewModel = accountViewModel) { if (!it.isNullOrBlank()) { @@ -106,16 +102,12 @@ fun RoomNameDisplay( modifier: Modifier, accountViewModel: AccountViewModel, ) { - val roomSubject by - accountViewModel - .userProfile() - .live() - .messages - .map { it.user.privateChatrooms[room]?.subject } - .distinctUntilChanged() - .observeAsState(accountViewModel.userProfile().privateChatrooms[room]?.subject) + val roomSubject by accountViewModel.account.chatroomList + .getOrCreatePrivateChatroom(room) + .subject + .collectAsStateWithLifecycle() - CrossfadeIfEnabled(targetState = roomSubject, modifier, label = "RoomNameDisplay", accountViewModel = accountViewModel) { + CrossfadeIfEnabled(targetState = roomSubject, modifier, accountViewModel = accountViewModel) { if (!it.isNullOrBlank()) { if (room.users.size > 1) { DisplayRoomSubject(it) @@ -173,13 +165,7 @@ fun ShortUsernameDisplay( fontWeight: FontWeight = FontWeight.Bold, accountViewModel: AccountViewModel, ) { - val userName by - baseUser - .live() - .metadata - .map { it.user.toBestShortFirstName() } - .distinctUntilChanged() - .observeAsState(baseUser.toBestShortFirstName()) + val userName by observeUserShortName(baseUser, accountViewModel) CrossfadeIfEnabled(targetState = userName, modifier = weight, accountViewModel = accountViewModel) { CreateTextWithEmoji( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index 2d0729965b..91e17d0f48 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -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 @@ -37,11 +37,9 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource +import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger -import com.vitorpamplona.amethyst.ui.actions.UserSuggestionAnchor -import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState @@ -58,6 +56,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.ChatFileUploader import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.SuccessfulUploads import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash @@ -105,16 +104,20 @@ class ChatNewMessageViewModel : IZapRaiser { val draftTag = DraftTagState() + lateinit var accountViewModel: AccountViewModel + lateinit var account: Account + init { viewModelScope.launch(Dispatchers.IO) { draftTag.versions.collectLatest { - sendDraft() + // don't save the first + if (it > 0) { + sendDraftSync() + } } } } - var accountViewModel: AccountViewModel? = null - var account: Account? = null var room: ChatroomKey? by mutableStateOf(null) var requiresNIP17: Boolean = false @@ -124,7 +127,7 @@ class ChatNewMessageViewModel : var uploadState by mutableStateOf(null) val iMetaAttachments = IMetaAttachments() - var uploadsWaitingToBeSent by mutableStateOf>(emptyList()) + var uploadsWaitingToBeSent by mutableStateOf>(emptyList()) override var message by mutableStateOf(TextFieldValue("")) @@ -166,11 +169,11 @@ class ChatNewMessageViewModel : // NIP17 Wrapped DMs / Group messages var nip17 by mutableStateOf(false) - fun lnAddress(): String? = account?.userProfile()?.info?.lnAddress() + fun lnAddress(): String? = account.userProfile().info?.lnAddress() - fun hasLnAddress(): Boolean = account?.userProfile()?.info?.lnAddress() != null + fun hasLnAddress(): Boolean = account.userProfile().info?.lnAddress() != null - fun user(): User? = account?.userProfile() + fun user(): User? = account.userProfile() fun init(accountVM: AccountViewModel) { this.accountViewModel = accountVM @@ -186,7 +189,7 @@ class ChatNewMessageViewModel : this.uploadState = ChatFileUploadState( - account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0], + account.settings.defaultFileServer, ) } @@ -228,7 +231,6 @@ class ChatNewMessageViewModel : urlPreviews.update(message) // creates a split with that author. - val accountViewModel = accountViewModel ?: return val quotedAuthor = quote.author ?: return if (quotedAuthor.pubkeyHex != accountViewModel.userProfile().pubkeyHex) { @@ -252,14 +254,12 @@ class ChatNewMessageViewModel : if (noteEvent is DraftEvent && noteAuthor != null) { viewModelScope.launch(Dispatchers.IO) { - accountViewModel?.createTempDraftNote(noteEvent) { innerNote -> - if (innerNote != null) { - val oldTag = (draft.event as? AddressableEvent)?.dTag() - if (oldTag != null) { - draftTag.set(oldTag) - } - loadFromDraft(innerNote) + accountViewModel.createTempDraftNote(noteEvent)?.let { innerNote -> + val oldTag = (draft.event as? AddressableEvent)?.dTag() + if (oldTag != null) { + draftTag.set(oldTag) } + loadFromDraft(innerNote) } } } @@ -267,20 +267,19 @@ class ChatNewMessageViewModel : private fun loadFromDraft(draft: Note) { val draftEvent = draft.event ?: return - val accountViewModel = accountViewModel ?: return - val localfowardZapTo = draftEvent.tags.zapSplitSetup() - val totalWeight = localfowardZapTo.sumOf { it.weight } + val localForwardZapTo = draftEvent.tags.zapSplitSetup() + val totalWeight = localForwardZapTo.sumOf { it.weight } forwardZapTo.value = SplitBuilder() - localfowardZapTo.forEach { + localForwardZapTo.forEach { if (it is ZapSplitSetup) { val user = LocalCache.getOrCreateUser(it.pubKeyHex) forwardZapTo.value.addItem(user, (it.weight / totalWeight).toFloat()) } - // don't support edditing old-style splits. + // don't support editing old-style splits. } forwardZapToEditting.value = TextFieldValue("") - wantsForwardZapTo = localfowardZapTo.isNotEmpty() + wantsForwardZapTo = localForwardZapTo.isNotEmpty() wantsToMarkAsSensitive = draftEvent.isSensitive() @@ -321,8 +320,8 @@ class ChatNewMessageViewModel : } } } else if (draftEvent is PrivateDmEvent) { - val recepientNpub = draftEvent.verifiedRecipientPubKey()?.let { Hex.decode(it).toNpub() } - toUsers = TextFieldValue("@$recepientNpub") + val recipientNPub = draftEvent.verifiedRecipientPubKey()?.let { Hex.decode(it).toNpub() } + toUsers = TextFieldValue("@$recipientNPub") val replyId = draftEvent.replyTo() if (replyId != null) { @@ -334,7 +333,7 @@ class ChatNewMessageViewModel : message = if (draftEvent is PrivateDmEvent) { - TextFieldValue(draftEvent.cachedContentFor(accountViewModel.account.signer) ?: "") + TextFieldValue(accountViewModel.account.privateDMDecryptionCache.cachedDM(draftEvent) ?: "") } else { TextFieldValue(draftEvent.content) } @@ -346,28 +345,16 @@ class ChatNewMessageViewModel : nip17 = draftEvent is NIP17Group } - fun sendPost(onDone: () -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - sendPostSync() - onDone() - } - } - suspend fun sendPostSync() { + val version = draftTag.current innerSendPost(null) - accountViewModel?.deleteDraft(draftTag.current) cancel() - } - - fun sendDraft() { - viewModelScope.launch(Dispatchers.IO) { - sendDraftSync() - } + accountViewModel.deleteDraft(version) } suspend fun sendDraftSync() { if (message.text.isBlank()) { - account?.deleteDraft(draftTag.current) + account.deleteDraft(draftTag.current) } else { innerSendPost(draftTag.current) } @@ -390,20 +377,21 @@ class ChatNewMessageViewModel : context: Context, onceUploaded: () -> Unit, ) { - val account = account ?: return val uploadState = uploadState ?: return - if (nip17) { - ChatFileUploader(account).justUploadNIP17(uploadState, viewModelScope, onError, context) { - uploadsWaitingToBeSent += it - draftTag.newVersion() - onceUploaded() - } - } else { - ChatFileUploader(account).justUploadNIP04(uploadState, viewModelScope, onError, context) { - uploadsWaitingToBeSent += it - draftTag.newVersion() - onceUploaded() + accountViewModel.runIOCatching { + if (nip17) { + ChatFileUploader(account).justUploadNIP17(uploadState, onError, context) { + uploadsWaitingToBeSent += it + draftTag.newVersion() + onceUploaded() + } + } else { + ChatFileUploader(account).justUploadNIP04(uploadState, onError, context) { + uploadsWaitingToBeSent += it + draftTag.newVersion() + onceUploaded() + } } } } @@ -414,31 +402,31 @@ class ChatNewMessageViewModel : onceUploaded: () -> Unit, ) { val room = room ?: return - val account = account ?: return val uploadState = uploadState ?: return - if (nip17) { - ChatFileUploader(account).justUploadNIP17(uploadState, viewModelScope, onError, context) { - ChatFileSender(room, account).sendNIP17(it) - draftTag.newVersion() - onceUploaded() - } - } else { - ChatFileUploader(account).justUploadNIP04(uploadState, viewModelScope, onError, context) { - ChatFileSender(room, account).sendNIP04(it) - draftTag.newVersion() - onceUploaded() + accountViewModel.runIOCatching { + if (nip17) { + ChatFileUploader(account).justUploadNIP17(uploadState, onError, context) { + ChatFileSender(room, account).sendNIP17(it) + draftTag.newVersion() + onceUploaded() + } + } else { + ChatFileUploader(account).justUploadNIP04(uploadState, onError, context) { + ChatFileSender(room, account).sendNIP04(it) + draftTag.newVersion() + onceUploaded() + } } } } - private fun innerSendPost(dTag: String?) { + private suspend fun innerSendPost(dTag: String?) { val room = room ?: return - val accountViewModel = accountViewModel ?: return val urls = findURLs(message.text) val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) - val emojis = findEmoji(message.text, accountViewModel.account.myEmojis.value) + val emojis = findEmoji(message.text, accountViewModel.account.emoji.myEmojis.value) val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() val message = message.text @@ -499,15 +487,17 @@ class ChatNewMessageViewModel : fun findEmoji( message: String, - myEmojiSet: List?, + myEmojiSet: List?, ): List { if (myEmojiSet == null) return emptyList() return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> - myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.url.url) } + myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link.url) } } } fun cancel() { + draftTag.rotate() + message = TextFieldValue("") subject = TextFieldValue("") @@ -535,16 +525,6 @@ class ChatNewMessageViewModel : iMetaAttachments.reset() emojiSuggestions?.reset() - - draftTag.rotate() - - NostrSearchEventOrUserDataSource.clear() - } - - fun deleteDraft() { - viewModelScope.launch(Dispatchers.IO) { - accountViewModel?.deleteDraft(draftTag.current) - } } fun addToMessage(it: String) { @@ -583,7 +563,7 @@ class ChatNewMessageViewModel : fun updateRoomFromUsersInput() { viewModelScope.launch(Dispatchers.Default) { delay(300) - val toUsersTagger = NewMessageTagger(toUsers.text, null, null, null, accountViewModel!!) + val toUsersTagger = NewMessageTagger(toUsers.text, null, null, accountViewModel) toUsersTagger.run() val users = toUsersTagger.pTags?.mapTo(mutableSetOf()) { it.pubkeyHex } @@ -627,7 +607,7 @@ class ChatNewMessageViewModel : toUsers = userSuggestions.replaceCurrentWord(toUsers, lastWord, item) updateRoomFromUsersInput() - val relayList = (LocalCache.getAddressableNoteIfExists(AdvertisedRelayListEvent.createAddressTag(item.pubkeyHex))?.event as? AdvertisedRelayListEvent)?.readRelays() + val relayList = (LocalCache.getAddressableNoteIfExists(AdvertisedRelayListEvent.createAddressTag(item.pubkeyHex))?.event as? AdvertisedRelayListEvent)?.readRelaysNorm() nip17 = relayList != null } @@ -638,7 +618,7 @@ class ChatNewMessageViewModel : draftTag.newVersion() } - fun autocompleteWithEmoji(item: Account.EmojiMedia) { + fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { val wordToInsert = ":${item.code}:" message = message.replaceCurrentWord(wordToInsert) @@ -649,14 +629,13 @@ class ChatNewMessageViewModel : draftTag.newVersion() } - fun autocompleteWithEmojiUrl(item: Account.EmojiMedia) { - val wordToInsert = item.url.url + " " + fun autocompleteWithEmojiUrl(item: EmojiPackState.EmojiMedia) { + val wordToInsert = item.link.url + " " viewModelScope.launch(Dispatchers.IO) { - iMetaAttachments.downloadAndPrepare( - item.url.url, - { Amethyst.instance.okHttpClients.getHttpClient(accountViewModel?.account?.shouldUseTorForImageDownload() ?: false) }, - ) + iMetaAttachments.downloadAndPrepare(item.link.url) { + Amethyst.instance.okHttpClients.getHttpClient(accountViewModel.account.privacyState.shouldUseTorForImageDownload(item.link.url)) + } } message = message.replaceCurrentWord(wordToInsert) @@ -706,7 +685,7 @@ class ChatNewMessageViewModel : override fun updateZapFromText() { viewModelScope.launch(Dispatchers.Default) { - val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), null, accountViewModel!!) + val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel) tagger.run() tagger.pTags?.forEach { taggedUser -> if (!forwardZapTo.value.items.any { it.key == taggedUser }) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/IMetaAttachments.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/IMetaAttachments.kt index 65a8e2eb1e..29703c3582 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/IMetaAttachments.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/IMetaAttachments.kt @@ -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 @@ -68,6 +68,10 @@ class IMetaAttachments { } } + fun add(imeta: IMetaTag) { + replace(imeta.url, imeta) + } + fun addAll(imetas: List) { imetas.forEach { replace(it.url, it) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index e78f36ff93..0b079e56d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -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 @@ -50,15 +50,11 @@ import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -71,8 +67,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -85,7 +79,6 @@ import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser -import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery @@ -93,9 +86,10 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.components.ZoomableContentView -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Nav -import com.vitorpamplona.amethyst.ui.navigation.routeToMessage +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage +import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton @@ -117,14 +111,10 @@ import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.ZapRaiserRequest import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapTo import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapToButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.PostButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.SuccessfulUploads import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Font14SP -import com.vitorpamplona.amethyst.ui.theme.HalfEndPadding -import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size35dp @@ -134,7 +124,6 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -162,18 +151,8 @@ fun NewGroupDMScreen( val context = LocalContext.current - val scope = rememberCoroutineScope() - - LaunchedEffect(key1 = postViewModel.draftTag) { - launch(Dispatchers.IO) { - postViewModel.draftTag.versions.collectLatest { - postViewModel.sendDraft() - } - } - } - LaunchedEffect(Unit) { - launch(Dispatchers.IO) { + withContext(Dispatchers.IO) { message?.ifBlank { null }?.let { postViewModel.updateMessage(TextFieldValue(it)) } @@ -184,52 +163,35 @@ fun NewGroupDMScreen( } } - DisposableEffect(Unit) { - NostrSearchEventOrUserDataSource.start() - - onDispose { - NostrSearchEventOrUserDataSource.clear() - NostrSearchEventOrUserDataSource.stop() - } - } - WatchAndLoadMyEmojiList(accountViewModel) Scaffold( topBar = { - TopAppBar( - actions = { - ActionButton(postViewModel, accountViewModel, nav) + PostingTopBar( + titleRes = R.string.private_message, + isActive = postViewModel::canPost, + onCancel = { + // uses the accountViewModel scope to avoid cancelling this + // function when the postViewModel is released + accountViewModel.runIOCatching { + postViewModel.sendDraftSync() + delay(100) + nav.popBack() + postViewModel.cancel() + } }, - title = { - Text( - text = stringRes(R.string.private_message), - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLarge, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - ) + onPost = { + // uses the accountViewModel scope to avoid cancelling this + // function when the postViewModel is released + accountViewModel.runIOCatching { + postViewModel.sendPostSync() + postViewModel.room?.let { + nav.nav(routeToMessage(it, null, null, null, accountViewModel)) + } + postViewModel.cancel() + } + nav.popBack() }, - navigationIcon = { - CloseButton( - modifier = HalfStartPadding, - onPress = { - scope.launch { - withContext(Dispatchers.IO) { - postViewModel.sendDraftSync() - postViewModel.cancel() - } - delay(100) - nav.popBack() - } - }, - ) - }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), ) }, ) { pad -> @@ -245,26 +207,6 @@ fun NewGroupDMScreen( } } -@Composable -fun ActionButton( - postViewModel: ChatNewMessageViewModel, - accountViewModel: AccountViewModel, - nav: INav, -) { - PostButton( - modifier = HalfEndPadding, - onPost = { - postViewModel.sendPost { - postViewModel.room?.let { - nav.nav(routeToMessage(it, null, null, null, accountViewModel)) - } - } - nav.popBack() - }, - isActive = postViewModel.canPost(), - ) -} - @Composable fun GroupDMScreenContent( postViewModel: ChatNewMessageViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt index 529649d36b..cc557c2d06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt @@ -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 @@ -28,7 +28,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -39,8 +38,8 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -146,7 +145,10 @@ fun EditField( isActive = channelScreenModel.canPost(), modifier = EditFieldTrailingIconModifier, ) { - channelScreenModel.sendPost(onSendNewMessage) + accountViewModel.runIOCatching { + channelScreenModel.sendPostSync() + onSendNewMessage() + } } }, leadingIcon = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ToggleNip17Button.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ToggleNip17Button.kt index 5838f113b7..34912b7c51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ToggleNip17Button.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ToggleNip17Button.kt @@ -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,9 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -34,11 +33,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.note.IncognitoIconOff -import com.vitorpamplona.amethyst.ui.note.IncognitoIconOn import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog +import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.IncognitoIconButtonModifier import com.vitorpamplona.amethyst.ui.theme.placeholderText import kotlinx.coroutines.launch @@ -72,19 +71,17 @@ fun ToggleNip17Button( }, ) { if (channelScreenModel.nip17) { - IncognitoIconOn( - modifier = - Modifier - .padding(top = 2.dp) - .size(20.dp), + Icon( + painter = painterRes(R.drawable.incognito, 2), + contentDescription = stringRes(id = R.string.accessibility_turn_off_sealed_message), + modifier = IncognitoIconButtonModifier, tint = MaterialTheme.colorScheme.primary, ) } else { - IncognitoIconOff( - modifier = - Modifier - .padding(top = 2.dp) - .size(20.dp), + Icon( + painter = painterRes(R.drawable.incognito_off, 2), + contentDescription = stringRes(id = R.string.accessibility_turn_on_sealed_message), + modifier = IncognitoIconButtonModifier, tint = MaterialTheme.colorScheme.placeholderText, ) } @@ -103,6 +100,7 @@ fun NewFeatureNIP17AlertDialog( title = stringRes(R.string.new_feature_nip17_might_not_be_available_title), textContent = stringRes(R.string.new_feature_nip17_might_not_be_available_description), buttonIconResource = R.drawable.incognito, + buttonIconReference = 3, buttonText = stringRes(R.string.new_feature_nip17_activate), onClickDoOnce = { scope.launch { onConfirm() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt index 25399c7e34..344fc1bf55 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt @@ -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,7 @@ class ChatFileSender( val chatroom: ChatroomKey, val account: Account, ) { - fun sendNIP17(uploads: List) { + suspend fun sendNIP17(uploads: List) { uploads.forEach { if (it.cipher != null) { sendNIP17(it.result, it.caption, it.contentWarningReason, it.cipher) @@ -42,7 +42,7 @@ class ChatFileSender( } } - fun sendNIP17( + suspend fun sendNIP17( result: UploadOrchestrator.OrchestratorResult.ServerResult, caption: String?, contentWarningReason: String?, @@ -73,7 +73,7 @@ class ChatFileSender( // NIP 04 // ------ - fun sendNIP04(uploads: List) { + suspend fun sendNIP04(uploads: List) { uploads.forEach { if (it.cipher == null) { sendNIP04(it.result, it.caption, it.contentWarningReason) @@ -81,7 +81,7 @@ class ChatFileSender( } } - fun sendNIP04( + suspend fun sendNIP04( result: UploadOrchestrator.OrchestratorResult.ServerResult, caption: String?, contentWarningReason: String?, @@ -103,7 +103,7 @@ class ChatFileSender( ) } - fun sendAll(uploads: List) { + suspend fun sendAll(uploads: List) { uploads.forEach { if (it.cipher != null) { sendNIP17(it.result, it.caption, it.contentWarningReason, it.cipher) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt index dfac45fb7b..3a8c60fb5a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt @@ -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 @@ -29,7 +29,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameOnlyDisplay diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt index bdfc312319..db5c43d959 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt @@ -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,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload import android.content.Context -import androidx.core.app.PendingIntentCompat.send -import coil3.util.CoilUtils.result import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.uploads.MediaCompressor @@ -30,9 +28,6 @@ import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip17Dm.files.encryption.AESGCM -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch class ChatFileUploader( val account: Account, @@ -41,100 +36,90 @@ class ChatFileUploader( // NIP 17 // ------ - fun justUploadNIP17( + suspend fun justUploadNIP17( viewState: ChatFileUploadState, - scope: CoroutineScope, onError: (title: String, message: String) -> Unit, context: Context, - onceUploaded: (List) -> Unit, + onceUploaded: suspend (List) -> Unit, ) { val orchestrator = viewState.multiOrchestrator ?: return + viewState.isUploadingImage = true - scope.launch(Dispatchers.Default) { - viewState.isUploadingImage = true + val cipher = AESGCM() - val cipher = AESGCM() + val results = + orchestrator.uploadEncrypted( + viewState.caption, + viewState.contentWarningReason, + MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider), + cipher, + viewState.selectedServer, + account, + context, + ) - val results = - orchestrator.uploadEncrypted( - scope, - viewState.caption, - viewState.contentWarningReason, - MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider), - cipher, - viewState.selectedServer, - account, - context, - ) - - if (results.allGood) { - val list = - results.successful.mapNotNull { state -> - if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { - SuccessfulUploads(state.result, viewState.caption, viewState.contentWarningReason, cipher) - } else { - null - } + if (results.allGood) { + val list = + results.successful.mapNotNull { state -> + if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { + SuccessfulUploads(state.result, viewState.caption, viewState.contentWarningReason, cipher) + } else { + null } + } - onceUploaded(list) - viewState.reset() - } else { - val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct() + onceUploaded(list) + viewState.reset() + } else { + val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct() - onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) - } - - viewState.isUploadingImage = false + onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) } + + viewState.isUploadingImage = false } // ------ // NIP 04 // ------ - fun justUploadNIP04( + suspend fun justUploadNIP04( viewState: ChatFileUploadState, - scope: CoroutineScope, onError: (title: String, message: String) -> Unit, context: Context, - onceUploaded: (List) -> Unit, + onceUploaded: suspend (List) -> Unit, ) { val orchestrator = viewState.multiOrchestrator ?: return + viewState.isUploadingImage = true - scope.launch(Dispatchers.Default) { - viewState.isUploadingImage = true + val results = + orchestrator.upload( + viewState.caption, + viewState.contentWarningReason, + MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider), + viewState.selectedServer, + account, + context, + ) - val results = - orchestrator.upload( - scope, - viewState.caption, - viewState.contentWarningReason, - MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider), - viewState.selectedServer, - account, - context, - ) - - if (results.allGood) { - val list = - results.successful.mapNotNull { state -> - if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { - SuccessfulUploads(state.result, viewState.caption, viewState.contentWarningReason, null) - } else { - null - } + if (results.allGood) { + val list = + results.successful.mapNotNull { state -> + if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { + SuccessfulUploads(state.result, viewState.caption, viewState.contentWarningReason, null) + } else { + null } + } - onceUploaded(list) - viewState.reset() - } else { - val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct() + onceUploaded(list) + viewState.reset() + } else { + val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct() - onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) - } - - viewState.isUploadingImage = false + onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) } + + viewState.isUploadingImage = false } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/SuccessfulUploads.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/SuccessfulUploads.kt index 276b7dc035..50fb293243 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/SuccessfulUploads.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/SuccessfulUploads.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt similarity index 87% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt index 09cd201880..b2119942e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt @@ -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,17 +18,19 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder class ChannelFeedFilter( val channel: Channel, val account: Account, ) : AdditiveFeedFilter() { - override fun feedKey(): String = channel.idHex + override fun feedKey() = channel // returns the last Note of each user. override fun feed(): List = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedViewModel.kt new file mode 100644 index 0000000000..be62287700 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedViewModel.kt @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Channel +import com.vitorpamplona.amethyst.ui.screen.FeedViewModel + +class ChannelFeedViewModel( + val channel: Channel, + val account: Account, +) : FeedViewModel(ChannelFeedFilter(channel, account)) { + class Factory( + val channel: Channel, + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = ChannelFeedViewModel(channel, account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssembler.kt new file mode 100644 index 0000000000..bc579d4545 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssembler.kt @@ -0,0 +1,54 @@ +/** + * 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.ui.screen.loggedIn.chats.publicChannels.datasource + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Channel +import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies.ChannelFromUserFilterSubAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies.ChannelPublicFilterSubAssembler +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient + +// This allows multiple screen to be listening to tags, even the same tag +class ChannelQueryState( + var channel: Channel, + var account: Account, +) + +class ChannelFilterAssembler( + client: NostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + // Requests the latest messages + ChannelPublicFilterSubAssembler(client, ::allKeys), + // Requests the latest messages by logged in user + ChannelFromUserFilterSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } + + override fun printStats() = group.forEach { it.printStats() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..0dc9e50f87 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt @@ -0,0 +1,43 @@ +/** + * 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.ui.screen.loggedIn.chats.publicChannels.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.model.Channel +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun ChannelFilterAssemblerSubscription( + channel: Channel, + dataSource: ChannelFilterAssembler, + accountViewModel: AccountViewModel, +) { + // different screens get different states + // even if they are tracking the same tag. + val state = + remember(accountViewModel.account) { + ChannelQueryState(channel, accountViewModel.account) + } + + KeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelFromUserFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelFromUserFilterSubAssembler.kt new file mode 100644 index 0000000000..df4767a042 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelFromUserFilterSubAssembler.kt @@ -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.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies + +import com.vitorpamplona.amethyst.model.Channel +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelQueryState +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +class ChannelFromUserFilterSubAssembler( + client: NostrClient, + allKeys: () -> Set, +) : PerUserAndFollowListEoseManager(client, allKeys) { + override fun updateFilter( + key: ChannelQueryState, + since: SincePerRelayMap?, + ): List? = + when (val channel = key.channel) { + is EphemeralChatChannel -> filterMyMessagesToEphemeralChat(channel, userHex(key), since) + is PublicChatChannel -> filterMyMessagesToPublicChat(channel, user(key).pubkeyHex, since) + is LiveActivitiesChannel -> filterMyMessagesToLiveActivities(channel, userHex(key), since) + else -> null + } + + fun userHex(key: ChannelQueryState) = key.account.userProfile().pubkeyHex + + override fun user(key: ChannelQueryState) = key.account.userProfile() + + override fun list(key: ChannelQueryState) = key.channel +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelPublicFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelPublicFilterSubAssembler.kt new file mode 100644 index 0000000000..2330cefaeb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelPublicFilterSubAssembler.kt @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies + +import com.vitorpamplona.amethyst.model.Channel +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelQueryState +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +class ChannelPublicFilterSubAssembler( + client: NostrClient, + allKeys: () -> Set, +) : PerUniqueIdEoseManager(client, allKeys) { + override fun updateFilter( + key: ChannelQueryState, + since: SincePerRelayMap?, + ): List? = + when (val channel = key.channel) { + is EphemeralChatChannel -> filterMessagesToEphemeralChat(channel, since) + is PublicChatChannel -> filterMessagesToPublicChat(channel, since) + is LiveActivitiesChannel -> filterMessagesToLiveActivities(channel, since) + else -> null + } + + override fun id(key: ChannelQueryState) = key.channel +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToEphemeralChat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToEphemeralChat.kt new file mode 100644 index 0000000000..f6cde11b5b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToEphemeralChat.kt @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies + +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter + +fun filterMessagesToEphemeralChat( + channel: EphemeralChatChannel, + since: SincePerRelayMap?, +): List = + channel.relays().toSet().map { + RelayBasedFilter( + relay = it, + filter = + Filter( + kinds = listOf(EphemeralChatEvent.KIND), + tags = + if (channel.roomId.id.isBlank()) { + mapOf("d" to listOf("_")) + } else { + mapOf("d" to listOfNotNull(channel.roomId.id)) + }, + limit = 200, + since = since?.get(it)?.time, + ), + ) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt new file mode 100644 index 0000000000..78aa37ce20 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt @@ -0,0 +1,44 @@ +/** + * 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.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies + +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent + +fun filterMessagesToLiveActivities( + channel: LiveActivitiesChannel, + since: SincePerRelayMap?, +): List = + channel.relays().toSet().map { + RelayBasedFilter( + relay = it, + filter = + Filter( + kinds = listOf(LiveActivitiesChatMessageEvent.KIND), + tags = mapOf("a" to listOfNotNull(channel.address.toValue())), + limit = 200, + since = since?.get(it)?.time, + ), + ) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToPublicChat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToPublicChat.kt new file mode 100644 index 0000000000..256c3862a1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToPublicChat.kt @@ -0,0 +1,44 @@ +/** + * 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.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies + +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent + +fun filterMessagesToPublicChat( + channel: PublicChatChannel, + since: SincePerRelayMap?, +): List? = + channel.relays().toSet().map { + RelayBasedFilter( + relay = it, + filter = + Filter( + kinds = listOf(ChannelMessageEvent.KIND), + tags = mapOf("e" to listOfNotNull(channel.idHex)), + limit = 200, + since = since?.get(it)?.time, + ), + ) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToEphemeralChat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToEphemeralChat.kt new file mode 100644 index 0000000000..84988cf436 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToEphemeralChat.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies + +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter + +fun filterMyMessagesToEphemeralChat( + channel: EphemeralChatChannel, + pubKey: HexKey, + since: SincePerRelayMap?, +): List = + channel.relays().toSet().map { + RelayBasedFilter( + relay = it, + filter = + Filter( + kinds = listOf(EphemeralChatEvent.KIND), + tags = + if (channel.roomId.id.isBlank()) { + mapOf("d" to listOf("_")) + } else { + mapOf("d" to listOfNotNull(channel.roomId.id)) + }, + authors = listOf(pubKey), + limit = 50, + since = since?.get(it)?.time, + ), + ) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToLiveActivities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToLiveActivities.kt new file mode 100644 index 0000000000..eef197437e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToLiveActivities.kt @@ -0,0 +1,47 @@ +/** + * 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.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies + +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent + +fun filterMyMessagesToLiveActivities( + channel: LiveActivitiesChannel, + pubKey: HexKey, + since: SincePerRelayMap?, +): List? = + channel.relays().toSet().map { + RelayBasedFilter( + relay = it, + filter = + Filter( + kinds = listOf(LiveActivitiesChatMessageEvent.KIND), + tags = mapOf("a" to listOfNotNull(channel.address.toValue())), + authors = listOf(pubKey), + limit = 50, + since = since?.get(it)?.time, + ), + ) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToPublicChat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToPublicChat.kt new file mode 100644 index 0000000000..3fded09c0b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToPublicChat.kt @@ -0,0 +1,47 @@ +/** + * 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.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies + +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent + +fun filterMyMessagesToPublicChat( + channel: PublicChatChannel, + pubKey: HexKey, + since: SincePerRelayMap?, +): List = + channel.relays().toSet().map { + RelayBasedFilter( + relay = it, + filter = + Filter( + kinds = listOf(ChannelMessageEvent.KIND), + tags = mapOf("e" to listOfNotNull(channel.idHex)), + authors = listOf(pubKey), + limit = 50, + since = since?.get(it)?.time, + ), + ) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/ChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/ChannelView.kt new file mode 100644 index 0000000000..c843e8cf16 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/ChannelView.kt @@ -0,0 +1,140 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId +import kotlinx.coroutines.launch + +@Composable +fun EphemeralChatChannelView( + channelId: RoomId?, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (channelId == null) return + + LoadEphemeralChatChannel(channelId, accountViewModel) { ephem -> + PrepareChannelViewModels( + baseChannel = ephem, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +@Composable +private fun PrepareChannelViewModels( + baseChannel: EphemeralChatChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val feedViewModel: ChannelFeedViewModel = + viewModel( + key = baseChannel.roomId.toKey() + "ChannelFeedViewModel", + factory = + ChannelFeedViewModel.Factory( + baseChannel, + accountViewModel.account, + ), + ) + + val channelScreenModel: ChannelNewMessageViewModel = viewModel() + channelScreenModel.init(accountViewModel) + channelScreenModel.load(baseChannel) + + ChannelView( + channel = baseChannel, + feedViewModel = feedViewModel, + newPostModel = channelScreenModel, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Composable +private fun ChannelView( + channel: EphemeralChatChannel, + feedViewModel: ChannelFeedViewModel, + newPostModel: ChannelNewMessageViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchLifecycleAndUpdateModel(feedViewModel) + ChannelFilterAssemblerSubscription(channel, accountViewModel.dataSources().channel, accountViewModel) + + Column(Modifier.fillMaxHeight()) { + Column( + modifier = + remember { + Modifier + .fillMaxHeight() + .padding(vertical = 0.dp) + .weight(1f, true) + }, + ) { + RefreshingChatroomFeedView( + viewModel = feedViewModel, + accountViewModel = accountViewModel, + nav = nav, + routeForLastRead = "Channel/${channel.roomId.toKey()}", + avoidDraft = newPostModel.draftTag, + onWantsToReply = newPostModel::reply, + onWantsToEditDraft = newPostModel::editFromDraft, + ) + } + + Spacer(modifier = DoubleVertSpacer) + + val scope = rememberCoroutineScope() + + // LAST ROW + EditFieldRow( + newPostModel, + accountViewModel, + onSendNewMessage = { + scope.launch { + feedViewModel.sendToTop() + } + }, + nav, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/EphemeralChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/EphemeralChatScreen.kt new file mode 100644 index 0000000000..a281a077f1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/EphemeralChatScreen.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.EphemeralChatTopBar +import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId + +@Composable +fun EphemeralChatScreen( + channelId: RoomId, + accountViewModel: AccountViewModel, + nav: INav, +) { + DisappearingScaffold( + isInvertedLayout = true, + topBar = { + LoadEphemeralChatChannel(channelId, accountViewModel) { + EphemeralChatTopBar(it, accountViewModel, nav) + } + }, + accountViewModel = accountViewModel, + ) { + Column(Modifier.padding(it)) { + EphemeralChatChannelView(channelId, accountViewModel, nav) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/LoadEphemeralChatChannel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/LoadEphemeralChatChannel.kt new file mode 100644 index 0000000000..b7c943c699 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/LoadEphemeralChatChannel.kt @@ -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.ui.screen.loggedIn.chats.publicChannels.ephemChat + +import androidx.compose.runtime.Composable +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.ui.note.produceStateIfNotNull +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId + +@Composable +fun LoadEphemeralChatChannel( + id: RoomId, + accountViewModel: AccountViewModel, + content: @Composable (EphemeralChatChannel) -> Unit, +) { + val channel = + produceStateIfNotNull(accountViewModel.getEphemeralChatChannelIfExists(id), id) { + value = accountViewModel.checkGetOrCreateEphemeralChatChannel(id) + } + + channel.value?.let { content(it) } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/EphemeralChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/EphemeralChatChannelHeader.kt new file mode 100644 index 0000000000..7a529fd864 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/EphemeralChatChannelHeader.kt @@ -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.ui.screen.loggedIn.chats.publicChannels.ephemChat.header + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.StdPadding + +@Composable +fun EphemeralChatChannelHeader( + baseChannel: EphemeralChatChannel, + sendToChannel: Boolean = false, + modifier: Modifier = StdPadding, + accountViewModel: AccountViewModel, + nav: INav, +) { + Column(Modifier.fillMaxWidth()) { + Column( + verticalArrangement = Arrangement.Center, + modifier = + modifier.clickable { + if (sendToChannel) { + nav.nav(routeFor(baseChannel)) + } + }, + ) { + ShortEphemeralChatChannelHeader( + baseChannel = baseChannel, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/EphemeralChatTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/EphemeralChatTopBar.kt new file mode 100644 index 0000000000..bcc891f4e4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/EphemeralChatTopBar.kt @@ -0,0 +1,45 @@ +/** + * 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.ui.screen.loggedIn.chats.publicChannels.ephemChat.header + +import androidx.compose.runtime.Composable +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun EphemeralChatTopBar( + baseChannel: EphemeralChatChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + TopBarExtensibleWithBackButton( + title = { + ShortEphemeralChatChannelHeader( + baseChannel = baseChannel, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + popBack = nav::popBack, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt new file mode 100644 index 0000000000..8a1a851880 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt @@ -0,0 +1,157 @@ +/** + * 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.ui.screen.loggedIn.chats.publicChannels.ephemChat.header + +import android.util.Log +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.FeatureSetType +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.service.Nip11CachedRetriever +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingChannel +import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.actions.JoinChatButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.actions.LeaveChatButton +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation + +@Composable +fun loadRelayInfo( + relay: NormalizedRelayUrl, + accountViewModel: AccountViewModel, +): State = + produceState( + Nip11CachedRetriever.getFromCache(relay), + relay, + ) { + accountViewModel.retrieveRelayDocument( + relay = relay, + onInfo = { + value = it + }, + onError = { url, errorCode, exceptionMessage -> + Log.e("RelayInfo", "Error loading relay info for ${url.url}: $errorCode - $exceptionMessage") + }, + ) + } + +@Composable +fun ShortEphemeralChatChannelHeader( + baseChannel: EphemeralChatChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val channelState by observeChannel(baseChannel, accountViewModel) + val channel = channelState?.channel as? EphemeralChatChannel ?: return + + Row(verticalAlignment = Alignment.CenterVertically) { + DrawRelayIcon(baseChannel, accountViewModel) + + Column( + Modifier + .padding(start = 10.dp) + .height(35.dp) + .weight(1f), + verticalArrangement = Arrangement.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = remember(channelState) { channel.toBestDisplayName() }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Row( + modifier = + Modifier + .height(Size35dp) + .padding(start = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ShortEphemeralChatActionOptions(channel, accountViewModel, nav) + } + } +} + +@Composable +private fun DrawRelayIcon( + channel: EphemeralChatChannel, + accountViewModel: AccountViewModel, +) { + val relayInfo by loadRelayInfo(channel.roomId.relayUrl, accountViewModel) + + RobohashFallbackAsyncImage( + robot = channel.roomId.toKey(), + model = relayInfo?.icon, + contentDescription = stringRes(R.string.profile_image), + contentScale = ContentScale.Crop, + modifier = HeaderPictureModifier, + loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, + ) +} + +@Composable +fun ShortEphemeralChatActionOptions( + channel: EphemeralChatChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + JoinEphemeralChatButtonIfNotAlreadyJoined(channel, accountViewModel, nav) +} + +@Composable +fun JoinEphemeralChatButtonIfNotAlreadyJoined( + channel: EphemeralChatChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val isFollowing by observeUserIsFollowingChannel(accountViewModel.account, channel, accountViewModel) + + if (!isFollowing) { + JoinChatButton(channel, accountViewModel, nav) + } else { + LeaveChatButton(channel, accountViewModel, nav) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/JoinChatButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/JoinChatButton.kt new file mode 100644 index 0000000000..403881bd07 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/JoinChatButton.kt @@ -0,0 +1,47 @@ +/** + * 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.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.actions + +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ButtonPadding +import com.vitorpamplona.amethyst.ui.theme.HalfHalfHorzModifier + +@Composable +fun JoinChatButton( + channel: EphemeralChatChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + FilledTonalButton( + modifier = HalfHalfHorzModifier, + onClick = { accountViewModel.follow(channel) }, + contentPadding = ButtonPadding, + ) { + Text(text = stringRes(R.string.join)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/LeaveChatButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/LeaveChatButton.kt new file mode 100644 index 0000000000..ea4ff20c2c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/LeaveChatButton.kt @@ -0,0 +1,47 @@ +/** + * 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.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.actions + +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ButtonPadding +import com.vitorpamplona.amethyst.ui.theme.HalfHalfHorzModifier + +@Composable +fun LeaveChatButton( + channel: EphemeralChatChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + FilledTonalButton( + modifier = HalfHalfHorzModifier, + onClick = { accountViewModel.unfollow(channel) }, + contentPadding = ButtonPadding, + ) { + Text(text = stringRes(R.string.leave)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/JoinButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/JoinButton.kt new file mode 100644 index 0000000000..88b47651ae --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/JoinButton.kt @@ -0,0 +1,43 @@ +/** + * 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.ui.screen.loggedIn.chats.publicChannels.ephemChat.metadata + +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun JoinButton( + onPost: () -> Unit = {}, + isActive: Boolean, + modifier: Modifier = Modifier, +) { + Button( + enabled = isActive, + modifier = modifier, + onClick = onPost, + ) { + Text(text = stringRes(R.string.join)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatMetaViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatMetaViewModel.kt new file mode 100644 index 0000000000..9a5525d291 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatMetaViewModel.kt @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.metadata + +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + +class NewEphemeralChatMetaViewModel : ViewModel() { + private var account: Account? = null + + val relayUrl = mutableStateOf(TextFieldValue()) + val channelName = mutableStateOf(TextFieldValue()) + + val canPost by derivedStateOf { + channelName.value.text.isNotBlank() && relayUrl.value.text.isNotBlank() + } + + fun load(account: Account) { + this.account = account + } + + fun buildRoom() = + RelayUrlNormalizer + .normalizeOrNull(relayUrl.value.text) + ?.let { RoomId(channelName.value.text, it) } + + /* + fun createOrUpdate(onDone: (RoomId) -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + account?.follow(, onDone) + clear() + } + }*/ + + fun clear() { + channelName.value = TextFieldValue() + relayUrl.value = TextFieldValue() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatScreen.kt new file mode 100644 index 0000000000..e25b6bcbfd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatScreen.kt @@ -0,0 +1,181 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.metadata + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.ui.theme.placeholderText + +@Composable +fun NewEphemeralChatScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val postViewModel: NewEphemeralChatMetaViewModel = viewModel() + postViewModel.load(accountViewModel.account) + + ChannelMetadataScaffold( + postViewModel = postViewModel, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Preview +@Composable +private fun DialogContentPreview() { + val accountViewModel = mockAccountViewModel() + val postViewModel: NewEphemeralChatMetaViewModel = viewModel() + postViewModel.load(accountViewModel.account) + + ThemeComparisonColumn { + ChannelMetadataScaffold( + postViewModel = postViewModel, + accountViewModel = accountViewModel, + nav = EmptyNav, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ChannelMetadataScaffold( + postViewModel: NewEphemeralChatMetaViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + Scaffold( + topBar = { + PostingTopBar( + titleRes = R.string.relay_chat, + isActive = postViewModel::canPost, + onCancel = { + nav.popBack() + }, + onPost = { + postViewModel.buildRoom()?.let { + nav.popBack() + nav.nav(routeFor(it)) + } + }, + ) + }, + ) { pad -> + LazyColumn( + Modifier + .fillMaxSize() + .padding( + start = 10.dp, + end = 10.dp, + top = pad.calculateTopPadding(), + bottom = pad.calculateBottomPadding(), + ).consumeWindowInsets(pad) + .imePadding(), + ) { + item { + SettingsCategory( + stringRes(R.string.relay_chat_title), + stringRes(R.string.relay_chat_explainer), + SettingsCategoryFirstModifier, + ) + + RelayUrl(postViewModel) + + Spacer(modifier = DoubleVertSpacer) + + ChannelName(postViewModel) + } + } + } +} + +@Composable +private fun ChannelName(postViewModel: NewEphemeralChatMetaViewModel) { + OutlinedTextField( + label = { Text(text = stringRes(R.string.channel_name)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.channelName.value, + onValueChange = { postViewModel.channelName.value = it }, + placeholder = { + Text( + text = stringRes(R.string.my_awesome_group), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + ) +} + +@Composable +private fun RelayUrl(postViewModel: NewEphemeralChatMetaViewModel) { + OutlinedTextField( + label = { Text(text = stringRes(R.string.group_relay)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.relayUrl.value, + onValueChange = { postViewModel.relayUrl.value = it }, + placeholder = { + Text( + text = "nos.lol", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/ChannelView.kt similarity index 65% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelView.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/ChannelView.kt index a5c77336ae..0d441a6d71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/ChannelView.kt @@ -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,46 +18,40 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.amethyst.model.Channel -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel -import com.vitorpamplona.amethyst.service.NostrChannelDataSource -import com.vitorpamplona.amethyst.service.NostrChannelDataSource.channel -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.note.LoadChannel -import com.vitorpamplona.amethyst.ui.screen.NostrChannelFeedViewModel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.ShowVideoStreaming +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import kotlinx.coroutines.launch @Composable -fun ChannelView( +fun PublicChatChannelView( channelId: String?, accountViewModel: AccountViewModel, nav: INav, ) { if (channelId == null) return - LoadChannel(channelId, accountViewModel) { + LoadPublicChatChannel(channelId, accountViewModel) { PrepareChannelViewModels( baseChannel = it, accountViewModel = accountViewModel, @@ -68,15 +62,15 @@ fun ChannelView( @Composable fun PrepareChannelViewModels( - baseChannel: Channel, + baseChannel: PublicChatChannel, accountViewModel: AccountViewModel, nav: INav, ) { - val feedViewModel: NostrChannelFeedViewModel = + val feedViewModel: ChannelFeedViewModel = viewModel( key = baseChannel.idHex + "ChannelFeedViewModel", factory = - NostrChannelFeedViewModel.Factory( + ChannelFeedViewModel.Factory( baseChannel, accountViewModel.account, ), @@ -97,46 +91,14 @@ fun PrepareChannelViewModels( @Composable fun ChannelView( - channel: Channel, - feedViewModel: NostrChannelFeedViewModel, + channel: PublicChatChannel, + feedViewModel: ChannelFeedViewModel, newPostModel: ChannelNewMessageViewModel, accountViewModel: AccountViewModel, nav: INav, ) { - NostrChannelDataSource.loadMessagesBetween(accountViewModel.account, channel) - - val lifeCycleOwner = LocalLifecycleOwner.current - - DisposableEffect(accountViewModel) { - NostrChannelDataSource.loadMessagesBetween(accountViewModel.account, channel) - NostrChannelDataSource.start() - feedViewModel.invalidateData(true) - - onDispose { - NostrChannelDataSource.clear() - NostrChannelDataSource.stop() - } - } - - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Channel Start") - NostrChannelDataSource.start() - feedViewModel.invalidateData(true) - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Channel Stop") - - NostrChannelDataSource.clear() - NostrChannelDataSource.stop() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } + WatchLifecycleAndUpdateModel(feedViewModel) + ChannelFilterAssemblerSubscription(channel, accountViewModel.dataSources().channel, accountViewModel) Column(Modifier.fillMaxHeight()) { Column( @@ -148,9 +110,6 @@ fun ChannelView( .weight(1f, true) }, ) { - if (channel is LiveActivitiesChannel) { - ShowVideoStreaming(channel, accountViewModel) - } RefreshingChatroomFeedView( viewModel = feedViewModel, accountViewModel = accountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelHeader.kt similarity index 55% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelHeader.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelHeader.kt index 8143f1b4a1..c5bfd8a49d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelHeader.kt @@ -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,74 +18,52 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.note.LoadChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.PublicChatChannelHeader -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivitiesChannelHeader import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.innerPostModifier +import com.vitorpamplona.quartz.nip01Core.core.HexKey @Composable -fun RenderChannelHeader( - channelNote: Note, - showVideo: Boolean, +fun RenderPublicChatChannelHeader( + channelId: HexKey, sendToChannel: Boolean, accountViewModel: AccountViewModel, nav: INav, ) { - channelNote.channelHex()?.let { - ChannelHeader( - channelHex = it, - showVideo = showVideo, - sendToChannel = sendToChannel, - modifier = MaterialTheme.colorScheme.innerPostModifier.padding(Size10dp), - accountViewModel = accountViewModel, - nav = nav, - ) - } + PublicChatChannelHeader( + channelHex = channelId, + sendToChannel = sendToChannel, + modifier = MaterialTheme.colorScheme.innerPostModifier.padding(Size10dp), + accountViewModel = accountViewModel, + nav = nav, + ) } @Composable -fun ChannelHeader( +fun PublicChatChannelHeader( channelHex: String, - showVideo: Boolean, - showFlag: Boolean = true, sendToChannel: Boolean = false, modifier: Modifier = StdPadding, accountViewModel: AccountViewModel, nav: INav, ) { - LoadChannel(channelHex, accountViewModel) { - when (it) { - is LiveActivitiesChannel -> - LiveActivitiesChannelHeader( - it, - showVideo, - showFlag, - sendToChannel, - modifier, - accountViewModel, - nav, - ) - is PublicChatChannel -> - PublicChatChannelHeader( - it, - sendToChannel, - modifier, - accountViewModel, - nav, - ) - } + LoadPublicChatChannel(channelHex, accountViewModel) { + PublicChatChannelHeader( + it, + sendToChannel, + modifier, + accountViewModel, + nav, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt new file mode 100644 index 0000000000..8c7cd6b93c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt @@ -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.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.PublicChatTopBar +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +@Composable +fun PublicChatChannelScreen( + channelId: HexKey?, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (channelId == null) return + + DisappearingScaffold( + isInvertedLayout = true, + topBar = { + LoadPublicChatChannel(channelId, accountViewModel) { + PublicChatTopBar(it, accountViewModel, nav) + } + }, + accountViewModel = accountViewModel, + ) { + Column(Modifier.padding(it)) { + PublicChatChannelView(channelId, accountViewModel, nav) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt index eed1fe2606..625232a29b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt @@ -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 @@ -32,7 +32,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -41,16 +40,16 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.lifecycle.distinctUntilChanged -import androidx.lifecycle.map import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.FeatureSetType -import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingChannel import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton @@ -66,7 +65,6 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.Size25dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.largeProfilePictureModifier -import com.vitorpamplona.quartz.nip01Core.tags.events.isTaggedEvent @Composable fun LongPublicChatChannelHeader( @@ -75,7 +73,7 @@ fun LongPublicChatChannelHeader( accountViewModel: AccountViewModel, nav: INav, ) { - val channelState by baseChannel.live.observeAsState() + val channelState by observeChannel(baseChannel, accountViewModel) val channel = channelState?.channel as? PublicChatChannel ?: return Spacer(StdVertSpacer) @@ -149,7 +147,7 @@ fun LongPublicChatChannelHeader( modifier = Modifier.width(75.dp), ) Spacer(DoubleHorzSpacer) - NoteAuthorPicture(note, nav, accountViewModel, Size25dp) + NoteAuthorPicture(note, Size25dp, accountViewModel = accountViewModel, nav = nav) Spacer(DoubleHorzSpacer) NoteUsernameDisplay(note, Modifier.weight(1f), accountViewModel = accountViewModel) } @@ -180,44 +178,42 @@ fun LongChannelActionOptions( accountViewModel: AccountViewModel, nav: INav, ) { - val isMe by - remember(accountViewModel) { - derivedStateOf { channel.creator == accountViewModel.account.userProfile() } - } - OpenChatButton(channel, accountViewModel, nav) LinkChatButton(channel, accountViewModel, nav) ShareChatButton(channel, accountViewModel, nav) + EditButtonIfIamCreator(channel, accountViewModel, nav) + + LeaveButtonIfFollowing(channel, accountViewModel, nav) +} + +@Composable +fun EditButtonIfIamCreator( + channel: PublicChatChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val isMe by + remember(accountViewModel) { + derivedStateOf { channel.creator == accountViewModel.account.userProfile() } + } + if (isMe) { EditButton(channel, accountViewModel, nav) } - - WatchChannelFollows(channel, accountViewModel) { isFollowing -> - if (isFollowing) { - LeaveChatButton(channel, accountViewModel, nav) - } - } } @Composable -fun WatchChannelFollows( +fun LeaveButtonIfFollowing( channel: PublicChatChannel, accountViewModel: AccountViewModel, - content: @Composable (Boolean) -> Unit, + nav: INav, ) { - val isFollowing by - accountViewModel - .userProfile() - .live() - .follows - .map { it.user.latestContactList?.isTaggedEvent(channel.idHex) ?: false } - .distinctUntilChanged() - .observeAsState( - accountViewModel.userProfile().latestContactList?.isTaggedEvent(channel.idHex) ?: false, - ) + val isFollowing by observeUserIsFollowingChannel(accountViewModel.account, channel, accountViewModel) - content(isFollowing) + if (isFollowing) { + LeaveChatButton(channel, accountViewModel, nav) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/PublicChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/PublicChatChannelHeader.kt index 5345932893..3279af82d2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/PublicChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/PublicChatChannelHeader.kt @@ -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 @@ -28,9 +28,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.StdPadding diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/PublicChatTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/PublicChatTopBar.kt index 2f034d4512..2ba8c3ca0c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/PublicChatTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/PublicChatTopBar.kt @@ -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,10 +21,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header import androidx.compose.runtime.Composable -import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav.popBack -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt index ea1461986a..e468db4a25 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt @@ -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 @@ -30,7 +30,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -39,10 +38,12 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.FeatureSetType -import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingChannel import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.ZapReaction import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -59,7 +60,7 @@ fun ShortPublicChatChannelHeader( accountViewModel: AccountViewModel, nav: INav, ) { - val channelState by baseChannel.live.observeAsState() + val channelState by observeChannel(baseChannel, accountViewModel) val channel = channelState?.channel as? PublicChatChannel ?: return Row(verticalAlignment = Alignment.CenterVertically) { @@ -129,9 +130,18 @@ fun ShortChannelActionOptions( } } - WatchChannelFollows(channel, accountViewModel) { isFollowing -> - if (!isFollowing) { - JoinChatButton(channel, accountViewModel, nav) - } + JoinChatButtonIfNotAlreadyJoined(channel, accountViewModel, nav) +} + +@Composable +fun JoinChatButtonIfNotAlreadyJoined( + channel: PublicChatChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val isFollowing by observeUserIsFollowingChannel(accountViewModel.account, channel, accountViewModel) + + if (!isFollowing) { + JoinChatButton(channel, accountViewModel, nav) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/EditChatButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/EditChatButton.kt index 5f1a1d652d..37e17b278b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/EditChatButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/EditChatButton.kt @@ -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,15 +27,12 @@ import androidx.compose.material.icons.filled.EditNote import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav.nav -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ZeroPadding diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/JoinChatButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/JoinChatButton.kt index 9d6af6e7e7..230a8b4f5e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/JoinChatButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/JoinChatButton.kt @@ -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,8 @@ import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonPadding diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/LeaveChatButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/LeaveChatButton.kt index 0468dae833..10a6233430 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/LeaveChatButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/LeaveChatButton.kt @@ -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,8 @@ import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonPadding diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/LinkChatButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/LinkChatButton.kt index 7d99496095..abfd1b7ce2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/LinkChatButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/LinkChatButton.kt @@ -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 @@ -31,11 +31,10 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat import androidx.core.net.toUri import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size20Modifier @@ -58,7 +57,7 @@ fun LinkChatButton( val intent = Intent(Intent.ACTION_VIEW, channel.toNostrUri().toUri()) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - ContextCompat.startActivity(context, intent, null) + context.startActivity(intent) val sendIntent = Intent().apply { @@ -74,7 +73,7 @@ fun LinkChatButton( stringRes(context, R.string.quick_action_copy_note_id), ) - ContextCompat.startActivity(context, shareIntent, null) + context.startActivity(shareIntent) }, contentPadding = ZeroPadding, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/OpenChatButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/OpenChatButton.kt index 01cf7c51c7..1a95f0364b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/OpenChatButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/OpenChatButton.kt @@ -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,18 +25,16 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.OpenInNew -import androidx.compose.material.icons.filled.OpenInNew import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat import androidx.core.net.toUri import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size20Modifier @@ -59,7 +57,7 @@ fun OpenChatButton( val intent = Intent(Intent.ACTION_VIEW, channel.toNostrUri().toUri()) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - ContextCompat.startActivity(context, intent, null) + context.startActivity(intent) }, contentPadding = ZeroPadding, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/ShareChatButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/ShareChatButton.kt index a86daef86c..57013ff8ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/ShareChatButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/ShareChatButton.kt @@ -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 @@ -28,15 +28,12 @@ import androidx.compose.material.icons.filled.Share import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.njumpLink import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -71,7 +68,7 @@ fun ShareChatButton( stringRes(context, R.string.quick_action_share), ) - ContextCompat.startActivity(context, shareIntent, null) + context.startActivity(shareIntent) }, contentPadding = ZeroPadding, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt index 715f8b1af6..868a31b12b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt @@ -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,8 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.metadata -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row +import android.graphics.Picture import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -37,32 +36,27 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDirection -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.ui.actions.uploads.SelectSingleFromGallery -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor -import com.vitorpamplona.amethyst.ui.note.LoadChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar +import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar +import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.CreateButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelName import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog @@ -70,11 +64,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditF import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer -import com.vitorpamplona.amethyst.ui.theme.MinHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier +import com.vitorpamplona.amethyst.ui.theme.SettingsCategorySpacingModifier import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions @Composable fun ChannelMetadataScreen( @@ -85,10 +80,8 @@ fun ChannelMetadataScreen( if (channelId == null) { ChannelMetadataScreen(null as PublicChatChannel?, accountViewModel, nav) } else { - LoadChannel(channelId, accountViewModel) { - if (it is PublicChatChannel) { - ChannelMetadataScreen(it, accountViewModel, nav) - } + LoadPublicChatChannel(channelId, accountViewModel) { + ChannelMetadataScreen(it, accountViewModel, nav) } } } @@ -134,61 +127,49 @@ private fun ChannelMetadataScaffold( ) { Scaffold( topBar = { - TopAppBar( - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer(modifier = MinHorzSpacer) - - Text( - text = stringRes(R.string.public_chat), - modifier = Modifier.weight(1f), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLarge, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - ) - - if (postViewModel.isNewChannel()) { - CreateButton( - onPost = { - postViewModel.createOrUpdate { - nav.nav(routeFor(it)) - } - nav.popBack() - }, - postViewModel.canPost, - ) - } else { - SaveButton( - onPost = { - postViewModel.createOrUpdate { } - nav.popBack() - }, - postViewModel.canPost, + if (postViewModel.isNewChannel()) { + CreatingTopBar( + titleRes = R.string.public_chat, + isActive = postViewModel::canPost, + onCancel = { + postViewModel.clear() + nav.popBack() + }, + onPost = { + try { + postViewModel.createOrUpdate { + nav.nav(routeFor(it)) + } + nav.popBack() + } catch (e: SignerExceptions.ReadOnlyException) { + accountViewModel.toastManager.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_sign_events, ) } - } - }, - navigationIcon = { - Row { - Spacer(modifier = StdHorzSpacer) - CloseButton( - onPress = { - postViewModel.clear() - nav.popBack() - }, - ) - } - }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - ) + }, + ) + } else { + SavingTopBar( + titleRes = R.string.public_chat, + isActive = postViewModel::canPost, + onCancel = { + postViewModel.clear() + nav.popBack() + }, + onPost = { + try { + postViewModel.createOrUpdate { } + nav.popBack() + } catch (e: SignerExceptions.ReadOnlyException) { + accountViewModel.toastManager.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_sign_events, + ) + } + }, + ) + } }, ) { pad -> val feedState by postViewModel.channelRelays.collectAsStateWithLifecycle() @@ -208,7 +189,7 @@ private fun ChannelMetadataScaffold( SettingsCategory( stringRes(R.string.public_chat_title), stringRes(R.string.public_chat_explainer), - Modifier.padding(bottom = 8.dp), + SettingsCategoryFirstModifier, ) ChannelName(postViewModel) @@ -224,10 +205,11 @@ private fun ChannelMetadataScaffold( SettingsCategory( stringRes(R.string.public_chat_relays_title), stringRes(R.string.public_chat_relays_explainer), + SettingsCategorySpacingModifier, ) } - itemsIndexed(feedState, key = { _, item -> "ChatRelays" + item.url }) { index, item -> + itemsIndexed(feedState, key = { _, item -> "ChatRelays" + item.relay }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteHomeRelay(item) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt index 6868bca4a4..dc2b2d24e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt @@ -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 @@ -32,7 +32,7 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.service.uploads.CompressorQuality import com.vitorpamplona.amethyst.service.uploads.MediaCompressor import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader @@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySet import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent @@ -51,9 +52,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlin.collections.isNotEmpty -import kotlin.collections.map -import kotlin.collections.plus import kotlin.coroutines.cancellation.CancellationException class ChannelMetadataViewModel : ViewModel() { @@ -87,7 +85,7 @@ class ChannelMetadataViewModel : ViewModel() { val relays = channel.info.relays ?.map { relaySetupInfoBuilder(it) } - ?.distinctBy { it.url } + ?.distinctBy { it.relay } _channelRelays.update { relays ?: emptyList() } } @@ -105,21 +103,18 @@ class ChannelMetadataViewModel : ViewModel() { channelName.value.text, channelDescription.value.text, channelPicture.value.text, - channelRelays.value.map { it.url }, + channelRelays.value.map { it.relay }, ) - account.signAndSendPrivatelyOrBroadcast( - template, - relayList = { it.channelInfo().relays }, - onDone = { - val channel = LocalCache.getOrCreateChannel(it.id) { PublicChatChannel(it) } - // follows the channel - account.follow(channel) - if (channel is PublicChatChannel) { - onDone(channel) - } - }, - ) + val signedResult = + account.signAndSendPrivatelyOrBroadcast( + template, + relayList = { it.channelInfo().relays }, + ) + val channel = LocalCache.getOrCreatePublicChatChannel(signedResult.id) + // follows the channel + account.follow(channel) + onDone(channel) } else { val event = channel.event @@ -131,7 +126,7 @@ class ChannelMetadataViewModel : ViewModel() { channelName.value.text, channelDescription.value.text, channelPicture.value.text, - channelRelays.value.map { it.url }, + channelRelays.value.map { it.relay }, hint, ) } else { @@ -141,21 +136,18 @@ class ChannelMetadataViewModel : ViewModel() { channelName.value.text, channelDescription.value.text, channelPicture.value.text, - channelRelays.value.map { it.url }, + channelRelays.value.map { it.relay }, eTag, ) } - account.signAndSendPrivatelyOrBroadcast( - template, - relayList = { it.channelInfo().relays }, - onDone = { - val channel = LocalCache.getOrCreateChannel(it.id) { PublicChatChannel(it) } - if (channel is PublicChatChannel) { - onDone(channel) - } - }, - ) + val signedResult = + account.signAndSendPrivatelyOrBroadcast( + template, + relayList = { it.channelInfo().relays }, + ) + val channel = LocalCache.getOrCreatePublicChatChannel(signedResult.id) + onDone(channel) } } @@ -164,7 +156,7 @@ class ChannelMetadataViewModel : ViewModel() { } fun addHomeRelay(relay: BasicRelaySetupInfo) { - if (_channelRelays.value.any { it.url == relay.url }) return + if (_channelRelays.value.any { it.relay == relay.relay }) return _channelRelays.update { it.plus(relay) } } @@ -218,7 +210,7 @@ class ChannelMetadataViewModel : ViewModel() { alt = null, sensitiveContent = null, serverBaseUrl = account.settings.defaultFileServer.baseUrl, - okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(it)) }, + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(it)) }, onProgress = {}, httpAuth = account::createHTTPAuthorization, context = context, @@ -231,7 +223,7 @@ class ChannelMetadataViewModel : ViewModel() { alt = null, sensitiveContent = null, serverBaseUrl = account.settings.defaultFileServer.baseUrl, - okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(it)) }, + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(it)) }, httpAuth = account::createBlossomUploadAuth, context = context, ) @@ -244,6 +236,9 @@ class ChannelMetadataViewModel : ViewModel() { onUploading(false) onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.server_did_not_provide_a_url_after_uploading)) } + } catch (_: SignerExceptions.ReadOnlyException) { + onUploading(false) + onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.login_with_a_private_key_to_be_able_to_upload)) } catch (e: Exception) { if (e is CancellationException) throw e onUploading(false) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/CreateButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/CreateButton.kt new file mode 100644 index 0000000000..e87169c317 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/CreateButton.kt @@ -0,0 +1,43 @@ +/** + * 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.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.metadata + +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun CreateButton( + onPost: () -> Unit = {}, + isActive: Boolean, + modifier: Modifier = Modifier, +) { + Button( + enabled = isActive, + modifier = modifier, + onClick = onPost, + ) { + Text(text = stringRes(R.string.create)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt new file mode 100644 index 0000000000..3cd0cd09ba --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt @@ -0,0 +1,142 @@ +/** + * 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.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.LoadLiveActivityChannel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import kotlinx.coroutines.launch + +@Composable +fun LiveActivityChannelView( + channelId: Address?, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (channelId == null) return + + LoadLiveActivityChannel(channelId, accountViewModel) { + PrepareChannelViewModels( + baseChannel = it, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +@Composable +fun PrepareChannelViewModels( + baseChannel: LiveActivitiesChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val feedViewModel: ChannelFeedViewModel = + viewModel( + key = baseChannel.address.toValue() + "ChannelFeedViewModel", + factory = + ChannelFeedViewModel.Factory( + baseChannel, + accountViewModel.account, + ), + ) + + val channelScreenModel: ChannelNewMessageViewModel = viewModel() + channelScreenModel.init(accountViewModel) + channelScreenModel.load(baseChannel) + + LiveActivityChannelView( + channel = baseChannel, + feedViewModel = feedViewModel, + newPostModel = channelScreenModel, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Composable +fun LiveActivityChannelView( + channel: LiveActivitiesChannel, + feedViewModel: ChannelFeedViewModel, + newPostModel: ChannelNewMessageViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchLifecycleAndUpdateModel(feedViewModel) + ChannelFilterAssemblerSubscription(channel, accountViewModel.dataSources().channel, accountViewModel) + + Column(Modifier.fillMaxHeight()) { + Column( + modifier = + remember { + Modifier + .fillMaxHeight() + .padding(vertical = 0.dp) + .weight(1f, true) + }, + ) { + ShowVideoStreaming(channel, accountViewModel) + RefreshingChatroomFeedView( + viewModel = feedViewModel, + accountViewModel = accountViewModel, + nav = nav, + routeForLastRead = "Channel/${channel.address.toValue()}", + avoidDraft = newPostModel.draftTag, + onWantsToReply = newPostModel::reply, + onWantsToEditDraft = newPostModel::editFromDraft, + ) + } + + Spacer(modifier = DoubleVertSpacer) + + val scope = rememberCoroutineScope() + + // LAST ROW + EditFieldRow( + newPostModel, + accountViewModel, + onSendNewMessage = { + scope.launch { + feedViewModel.sendToTop() + } + }, + nav, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivitiesChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivitiesChannelHeader.kt index bd87cee07a..7005fcde5d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivitiesChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivitiesChannelHeader.kt @@ -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 @@ -28,9 +28,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.StdPadding @@ -64,9 +64,9 @@ fun LiveActivitiesChannelHeader( ) { ShortLiveActivityChannelHeader( baseChannel = baseChannel, + showFlag = showFlag, accountViewModel = accountViewModel, nav = nav, - showFlag = showFlag, ) if (expanded.value) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt similarity index 68% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelScreen.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt index 99f1c3b111..4136ed19d0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt @@ -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,24 +18,22 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel -import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.note.LoadChannel +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.LoadLiveActivityChannel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.PublicChatTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveActivityTopBar +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address @Composable -fun ChannelScreen( - channelId: String?, +fun LiveActivityChannelScreen( + channelId: Address?, accountViewModel: AccountViewModel, nav: INav, ) { @@ -44,17 +42,14 @@ fun ChannelScreen( DisappearingScaffold( isInvertedLayout = true, topBar = { - LoadChannel(channelId, accountViewModel) { - when (it) { - is PublicChatChannel -> PublicChatTopBar(it, accountViewModel, nav) - is LiveActivitiesChannel -> LiveActivityTopBar(it, accountViewModel, nav) - } + LoadLiveActivityChannel(channelId, accountViewModel) { + LiveActivityTopBar(it, accountViewModel, nav) } }, accountViewModel = accountViewModel, ) { Column(Modifier.padding(it)) { - ChannelView(channelId, accountViewModel, nav) + LiveActivityChannelView(channelId, accountViewModel, nav) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveChannelActionOptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveChannelActionOptions.kt deleted file mode 100644 index 6f4ef4ddfe..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveChannelActionOptions.kt +++ /dev/null @@ -1,77 +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.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities - -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.note.LikeReaction -import com.vitorpamplona.amethyst.ui.note.ZapReaction -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.theme.RowColSpacing -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer -import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag - -@Composable -fun LiveChannelActionOptions( - channel: LiveActivitiesChannel, - showFlag: Boolean = true, - accountViewModel: AccountViewModel, - nav: INav, -) { - val isLive by remember(channel) { derivedStateOf { channel.info?.status() == StatusTag.STATUS.LIVE.code } } - - val note = remember(channel.idHex) { LocalCache.getNoteIfExists(channel.idHex) } - - note?.let { - if (showFlag && isLive) { - LiveFlag() - Spacer(modifier = StdHorzSpacer) - } - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = RowColSpacing, - ) { - LikeReaction( - baseNote = it, - grayTint = MaterialTheme.colorScheme.onSurface, - accountViewModel = accountViewModel, - nav, - ) - } - Spacer(modifier = StdHorzSpacer) - ZapReaction( - baseNote = it, - grayTint = MaterialTheme.colorScheme.onSurface, - accountViewModel = accountViewModel, - nav = nav, - ) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LongLiveActivityChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LongLiveActivityChannelHeader.kt index 9b19653f7c..6f4d0918c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LongLiveActivityChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LongLiveActivityChannelHeader.kt @@ -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,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53 import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -31,7 +32,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -40,14 +40,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay import com.vitorpamplona.amethyst.ui.note.UsernameDisplay @@ -60,8 +61,8 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.Size25dp import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags -import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -77,46 +78,16 @@ fun LongLiveActivityChannelHeader( accountViewModel: AccountViewModel, nav: INav, ) { - val channelState by baseChannel.live.observeAsState() + val channelState by observeChannel(baseChannel, accountViewModel) val channel = channelState?.channel as? LiveActivitiesChannel ?: return + val activity = channel.info ?: return + val callbackUri = remember(channel) { channel.toNostrUri() } - Row( - lineModifier, - ) { - val summary = remember(channelState) { channel.summary()?.ifBlank { null } } - - Column( - Modifier.weight(1f), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - val defaultBackground = MaterialTheme.colorScheme.background - val background = remember { mutableStateOf(defaultBackground) } - - val tags = remember(channelState) { baseChannel.info?.tags?.toImmutableListOfLists() ?: EmptyTagList } - - TranslatableRichTextViewer( - content = summary ?: stringRes(id = R.string.groups_no_descriptor), - canPreview = false, - quotesLeft = 1, - tags = tags, - backgroundColor = background, - id = baseChannel.idHex, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - if (summary != null) { - baseChannel.info?.let { - if (it.hasHashtags()) { - DisplayUncitedHashtags(it, summary, nav) - } - } - } - } + Row(lineModifier) { + RenderSummary(activity, callbackUri, accountViewModel, nav) } - LoadNote(baseNoteHex = channel.idHex, accountViewModel) { loadingNote -> + LoadAddressableNote(channel.address, accountViewModel) { loadingNote -> loadingNote?.let { note -> Row( lineModifier, @@ -129,7 +100,7 @@ fun LongLiveActivityChannelHeader( modifier = Modifier.width(75.dp), ) Spacer(DoubleHorzSpacer) - NoteAuthorPicture(note, nav, accountViewModel, Size25dp) + NoteAuthorPicture(note, Size25dp, accountViewModel = accountViewModel, nav = nav) Spacer(DoubleHorzSpacer) NoteUsernameDisplay(note, Modifier.weight(1f), accountViewModel = accountViewModel) } @@ -181,7 +152,10 @@ fun LongLiveActivityChannelHeader( ) { it.first.role?.let { it1 -> Text( - text = it1.capitalize(Locale.ROOT), + text = + it1.replaceFirstChar { + if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() + }, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(55.dp), @@ -194,3 +168,36 @@ fun LongLiveActivityChannelHeader( } } } + +@Composable +private fun RowScope.RenderSummary( + activity: LiveActivitiesEvent, + callbackUri: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val summary = activity.summary() ?: stringRes(id = R.string.groups_no_descriptor) + + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + val defaultBackground = MaterialTheme.colorScheme.background + val background = remember { mutableStateOf(defaultBackground) } + + TranslatableRichTextViewer( + content = summary, + canPreview = false, + quotesLeft = 1, + tags = activity.tags.toImmutableListOfLists(), + backgroundColor = background, + id = activity.id, + callbackUri = callbackUri, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (activity.hasHashtags()) { + DisplayUncitedHashtags(activity, summary, callbackUri, accountViewModel, nav) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShortLiveActivityChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShortLiveActivityChannelHeader.kt index c1767758d0..0517c843cf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShortLiveActivityChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShortLiveActivityChannelHeader.kt @@ -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,36 +23,65 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53 import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.ZapReaction import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.RowColSpacing import com.vitorpamplona.amethyst.ui.theme.Size34dp import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent @Composable fun ShortLiveActivityChannelHeader( baseChannel: LiveActivitiesChannel, + showFlag: Boolean, accountViewModel: AccountViewModel, nav: INav, - showFlag: Boolean, ) { - val channelState by baseChannel.live.observeAsState() + val channelState by observeChannel(baseChannel, accountViewModel) val channel = channelState?.channel as? LiveActivitiesChannel ?: return + ShortLiveActivityChannelHeader( + name = channel.toBestDisplayName(), + creator = channel.creator, + liveActivitiesEvent = channel.info, + showFlag = showFlag, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Composable +fun ShortLiveActivityChannelHeader( + name: String, + creator: User?, + liveActivitiesEvent: LiveActivitiesEvent?, + showFlag: Boolean, + accountViewModel: AccountViewModel, + nav: INav, +) { Row(verticalAlignment = Alignment.CenterVertically) { - channel.creator?.let { + creator?.let { UserPicture( user = it, size = Size34dp, @@ -71,21 +100,60 @@ fun ShortLiveActivityChannelHeader( ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = remember(channelState) { channel.toBestDisplayName() }, + text = name, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } - Row( - modifier = - Modifier - .height(Size35dp) - .padding(start = 5.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - LiveChannelActionOptions(channel, showFlag, accountViewModel, nav) + liveActivitiesEvent?.let { + Row( + modifier = + Modifier + .height(Size35dp) + .padding(start = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + LiveChannelActionOptions(it, showFlag, accountViewModel, nav) + } } } } + +@Composable +fun LiveChannelActionOptions( + activity: LiveActivitiesEvent, + showFlag: Boolean = true, + accountViewModel: AccountViewModel, + nav: INav, +) { + val isLive by remember(activity) { derivedStateOf { activity.isLive() } } + + if (showFlag && isLive) { + LiveFlag() + Spacer(modifier = StdHorzSpacer) + } + + val note = remember(activity) { LocalCache.getAddressableNoteIfExists(activity.address()) } + note?.let { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = RowColSpacing, + ) { + LikeReaction( + baseNote = it, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav, + ) + } + Spacer(modifier = StdHorzSpacer) + ZapReaction( + baseNote = it, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShowVideoStreaming.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShowVideoStreaming.kt index e8c3d106d8..98167d056e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShowVideoStreaming.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShowVideoStreaming.kt @@ -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,14 +24,12 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.layout.ContentScale -import androidx.lifecycle.distinctUntilChanged -import androidx.lifecycle.map import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannelInfo import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -44,46 +42,48 @@ fun ShowVideoStreaming( accountViewModel: AccountViewModel, ) { baseChannel.info?.let { - SensitivityWarning( - event = it, - accountViewModel = accountViewModel, - ) { - val streamingInfoEvent by - baseChannel.live - .map { - (it.channel as? LiveActivitiesChannel)?.info - }.distinctUntilChanged() - .observeAsState(baseChannel.info) - - streamingInfoEvent?.let { event -> - event.streaming()?.let { url -> - CrossfadeCheckIfVideoIsOnline(url, accountViewModel) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = StreamingHeaderModifier, - ) { - val zoomableUrlVideo = - remember(streamingInfoEvent) { - MediaUrlVideo( - url = url, - description = baseChannel.toBestDisplayName(), - artworkUri = event.image(), - authorName = baseChannel.creatorName(), - uri = baseChannel.toNAddr(), - ) - } - - ZoomableContentView( - content = zoomableUrlVideo, - roundedCorner = false, - contentScale = ContentScale.FillWidth, - accountViewModel = accountViewModel, - ) - } + val streamingInfoEvent by observeChannelInfo(baseChannel, accountViewModel) + streamingInfoEvent?.let { event -> + event.streaming()?.let { url -> + val zoomableUrlVideo = + remember(streamingInfoEvent) { + MediaUrlVideo( + url = url, + description = event.title() ?: baseChannel.toBestDisplayName(), + artworkUri = event.image(), + authorName = baseChannel.creatorName(), + uri = baseChannel.toNAddr(), + ) } + + SensitivityWarning( + event = event, + accountViewModel = accountViewModel, + ) { + RenderStreaming(zoomableUrlVideo, accountViewModel) } } } } } + +@Composable +private fun RenderStreaming( + media: MediaUrlVideo, + accountViewModel: AccountViewModel, +) { + CrossfadeCheckIfVideoIsOnline(media.url, accountViewModel) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = StreamingHeaderModifier, + ) { + ZoomableContentView( + content = media, + roundedCorner = false, + contentScale = ContentScale.FillWidth, + accountViewModel = accountViewModel, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/StreamingStatusFlags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/StreamingStatusFlags.kt index e2957d7fdd..81d36e517e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/StreamingStatusFlags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/StreamingStatusFlags.kt @@ -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 @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.SmallBorder import com.vitorpamplona.amethyst.ui.theme.liveStreamTag +import com.vitorpamplona.quartz.utils.TimeUtils import java.text.DateFormat import java.text.SimpleDateFormat import java.util.Date @@ -92,11 +93,15 @@ fun OfflineFlag() { fun ScheduledFlag(starts: Long?) { val startsIn = starts?.let { - SimpleDateFormat - .getDateTimeInstance( - DateFormat.SHORT, - DateFormat.SHORT, - ).format(Date(starts * 1000)) + if (it > TimeUtils.now()) { + SimpleDateFormat + .getDateTimeInstance( + DateFormat.SHORT, + DateFormat.SHORT, + ).format(Date(starts * 1000)) + } else { + null + } } Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveActivityTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveActivityTopBar.kt index 8a713e17e4..fa6e0516e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveActivityTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveActivityTopBar.kt @@ -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,9 +21,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header import androidx.compose.runtime.Composable -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LongLiveActivityChannelHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.ShortLiveActivityChannelHeader @@ -38,9 +38,9 @@ fun LiveActivityTopBar( title = { ShortLiveActivityChannelHeader( baseChannel = baseChannel, + showFlag = true, accountViewModel = accountViewModel, nav = nav, - showFlag = true, ) }, extendableRow = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt index b90f6346ff..ac5d4d9ea6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt @@ -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 @@ -35,11 +35,11 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.FeatureSetType -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel -import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 01d933ea7a..c91abed59e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -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 @@ -37,18 +37,17 @@ import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Channel -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.PublicChatChannel import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState +import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.uploads.MediaCompressor import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger -import com.vitorpamplona.amethyst.ui.actions.UserSuggestionAnchor -import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState @@ -58,13 +57,16 @@ import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.SplitBuilder import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash @@ -102,13 +104,16 @@ open class ChannelNewMessageViewModel : init { viewModelScope.launch(Dispatchers.IO) { draftTag.versions.collectLatest { - sendDraft() + // don't save the first + if (it > 0) { + sendDraftSync() + } } } } - var accountViewModel: AccountViewModel? = null - var account: Account? = null + lateinit var accountViewModel: AccountViewModel + lateinit var account: Account var channel: Channel? = null val replyTo = mutableStateOf(null) @@ -147,11 +152,11 @@ open class ChannelNewMessageViewModel : var wantsZapraiser by mutableStateOf(false) var zapRaiserAmount by mutableStateOf(null) - fun lnAddress(): String? = account?.userProfile()?.info?.lnAddress() + fun lnAddress(): String? = account.userProfile().info?.lnAddress() - fun hasLnAddress(): Boolean = account?.userProfile()?.info?.lnAddress() != null + fun hasLnAddress(): Boolean = account.userProfile().info?.lnAddress() != null - fun user(): User? = account?.userProfile() + fun user(): User? = account.userProfile() open fun init(accountVM: AccountViewModel) { this.accountViewModel = accountVM @@ -165,10 +170,7 @@ open class ChannelNewMessageViewModel : this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM) - this.uploadState = - ChatFileUploadState( - account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0], - ) + this.uploadState = ChatFileUploadState(account.settings.defaultFileServer) } open fun load(channel: Channel) { @@ -191,14 +193,12 @@ open class ChannelNewMessageViewModel : if (noteEvent is DraftEvent && noteAuthor != null) { viewModelScope.launch(Dispatchers.IO) { - accountViewModel?.createTempDraftNote(noteEvent) { innerNote -> - if (innerNote != null) { - val oldTag = (draft.event as? AddressableEvent)?.dTag() - if (oldTag != null) { - draftTag.set(oldTag) - } - loadFromDraft(innerNote) + accountViewModel.createTempDraftNote(noteEvent)?.let { innerNote -> + val oldTag = (draft.event as? AddressableEvent)?.dTag() + if (oldTag != null) { + draftTag.set(oldTag) } + loadFromDraft(innerNote) } } } @@ -239,14 +239,14 @@ open class ChannelNewMessageViewModel : if (draftEvent as? ChannelMessageEvent != null) { val replyId = draftEvent.reply()?.eventId if (replyId != null) { - accountViewModel?.checkGetOrCreateNote(replyId) { + accountViewModel.checkGetOrCreateNote(replyId) { replyTo.value = it } } } else if (draftEvent as? LiveActivitiesChatMessageEvent != null) { val replyId = draftEvent.reply()?.eventId if (replyId != null) { - accountViewModel?.checkGetOrCreateNote(replyId) { + accountViewModel.checkGetOrCreateNote(replyId) { replyTo.value = it } } @@ -268,26 +268,18 @@ open class ChannelNewMessageViewModel : suspend fun sendPostSync() { val template = createTemplate() ?: return - val channelRelays = channel?.relays() ?: emptyList() - - accountViewModel?.account?.signAndSendPrivately(template, channelRelays) - - accountViewModel?.deleteDraft(draftTag.current) + val channelRelays = channel?.relays() ?: emptySet() + val version = draftTag.current cancel() - } - fun sendDraft() { - viewModelScope.launch(Dispatchers.IO) { - sendDraftSync() - } + accountViewModel.account.signAndSendPrivately(template, channelRelays) + accountViewModel.deleteDraft(version) } suspend fun sendDraftSync() { - val accountViewModel = accountViewModel ?: return - if (message.text.isBlank()) { - account?.deleteDraft(draftTag.current) + account.deleteDraft(draftTag.current) } else { val template = createTemplate() ?: return accountViewModel.account.createAndSendDraft(draftTag.current, template) @@ -302,9 +294,21 @@ open class ChannelNewMessageViewModel : onError: (title: String, message: String) -> Unit, context: Context, onceUploaded: () -> Unit, + ) = try { + uploadUnsafe(onError, context, onceUploaded) + } catch (_: SignerExceptions.ReadOnlyException) { + onError( + stringRes(context, R.string.read_only_user), + stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events), + ) + } + + fun uploadUnsafe( + onError: (title: String, message: String) -> Unit, + context: Context, + onceUploaded: () -> Unit, ) { viewModelScope.launch(Dispatchers.Default) { - val myAccount = account ?: return@launch val uploadState = uploadState ?: return@launch val myMultiOrchestrator = uploadState.multiOrchestrator ?: return@launch @@ -313,32 +317,30 @@ open class ChannelNewMessageViewModel : val results = myMultiOrchestrator.upload( - viewModelScope, uploadState.caption, uploadState.contentWarningReason, MediaCompressor.intToCompressorQuality(uploadState.mediaQualitySlider), uploadState.selectedServer, - myAccount, + account, context, ) if (results.allGood) { - results.successful.forEach { - if (it.result is UploadOrchestrator.OrchestratorResult.NIP95Result) { - account?.createNip95(it.result.bytes, headerInfo = it.result.fileHeader, uploadState.caption, uploadState.contentWarningReason) { nip95 -> - nip95attachments = nip95attachments + nip95 - val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } + results.successful.forEach { upload -> + if (upload.result is UploadOrchestrator.OrchestratorResult.NIP95Result) { + val nip95 = account.createNip95(upload.result.bytes, headerInfo = upload.result.fileHeader, uploadState.caption, uploadState.contentWarningReason) + nip95attachments = nip95attachments + nip95 + val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) } - note?.let { - message = message.insertUrlAtCursor(it.toNostrUri()) - } - - urlPreview = findUrlInMessage() + note?.let { + message = message.insertUrlAtCursor(it.toNostrUri()) } - } else if (it.result is UploadOrchestrator.OrchestratorResult.ServerResult) { - iMetaAttachments.add(it.result, uploadState.caption, uploadState.contentWarningReason) - message = message.insertUrlAtCursor(it.result.url) + urlPreview = findUrlInMessage() + } else if (upload.result is UploadOrchestrator.OrchestratorResult.ServerResult) { + iMetaAttachments.add(upload.result, uploadState.caption, uploadState.contentWarningReason) + + message = message.insertUrlAtCursor(upload.result.url) urlPreview = findUrlInMessage() } } @@ -358,21 +360,18 @@ open class ChannelNewMessageViewModel : private suspend fun createTemplate(): EventTemplate? { val channel = channel ?: return null - val accountViewModel = accountViewModel ?: return null - val tagger = NewMessageTagger( message = message.text, pTags = listOfNotNull(replyTo.value?.author), eTags = listOfNotNull(replyTo.value), - channelHex = channel.idHex, dao = accountViewModel, ) tagger.run() val urls = findURLs(message.text) val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) - val emojis = findEmoji(message.text, accountViewModel.account.myEmojis.value) + val emojis = findEmoji(message.text, accountViewModel.account.emoji.myEmojis.value) val channelRelays = channel.relays() val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() @@ -434,7 +433,7 @@ open class ChannelNewMessageViewModel : imetas(usedAttachments) } } else if (activity != null) { - val hint = EventHintBundle(activity, channelRelays.firstOrNull() ?: replyingToEvent?.relay) + val hint = EventHintBundle(activity, channelRelays.firstOrNull()) LiveActivitiesChatMessageEvent.message(tagger.message, hint) { hashtags(findHashtags(tagger.message)) @@ -454,6 +453,19 @@ open class ChannelNewMessageViewModel : imetas(usedAttachments) } } + } else if (channel is EphemeralChatChannel) { + EphemeralChatEvent.build( + tagger.message, + channel.roomId.relayUrl, + channel.roomId.id, + ) { + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + emojis(emojis) + imetas(usedAttachments) + } } else { null } @@ -461,15 +473,17 @@ open class ChannelNewMessageViewModel : fun findEmoji( message: String, - myEmojiSet: List?, + myEmojiSet: List?, ): List { if (myEmojiSet == null) return emptyList() return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> - myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.url.url) } + myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link.url) } } } open fun cancel() { + draftTag.rotate() + message = TextFieldValue("") replyTo.value = null @@ -494,16 +508,6 @@ open class ChannelNewMessageViewModel : iMetaAttachments.reset() emojiSuggestions?.reset() - - draftTag.rotate() - - NostrSearchEventOrUserDataSource.clear() - } - - fun deleteDraft() { - viewModelScope.launch(Dispatchers.IO) { - accountViewModel?.deleteDraft(draftTag.current) - } } open fun findUrlInMessage(): String? = RichTextParser().parseValidUrls(message.text).firstOrNull() @@ -554,7 +558,7 @@ open class ChannelNewMessageViewModel : draftTag.newVersion() } - open fun autocompleteWithEmoji(item: Account.EmojiMedia) { + open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { val wordToInsert = ":${item.code}:" message = message.replaceCurrentWord(wordToInsert) @@ -563,14 +567,15 @@ open class ChannelNewMessageViewModel : draftTag.newVersion() } - open fun autocompleteWithEmojiUrl(item: Account.EmojiMedia) { - val wordToInsert = item.url.url + " " + open fun autocompleteWithEmojiUrl(item: EmojiPackState.EmojiMedia) { + val wordToInsert = item.link.url + " " viewModelScope.launch(Dispatchers.IO) { - iMetaAttachments.downloadAndPrepare( - item.url.url, - { Amethyst.instance.okHttpClients.getHttpClient(accountViewModel?.account?.shouldUseTorForImageDownload() ?: false) }, - ) + iMetaAttachments.downloadAndPrepare(item.link.url) { + Amethyst.instance.okHttpClients.getHttpClient( + accountViewModel.account.privacyState.shouldUseTorForImageDownload(item.link.url), + ) + } } message = message.replaceCurrentWord(wordToInsert) @@ -617,7 +622,7 @@ open class ChannelNewMessageViewModel : fun updateZapFromText() { viewModelScope.launch(Dispatchers.Default) { - val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), null, accountViewModel!!) + val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel) tagger.run() tagger.pTags?.forEach { taggedUser -> if (!forwardZapTo.items.any { it.key == taggedUser }) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt index f32eac0921..c2f20ad7ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt @@ -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 @@ -39,7 +39,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt index 45135dbac3..a2913fd3c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt @@ -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 @@ -48,10 +48,9 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav.nav -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route -import com.vitorpamplona.amethyst.ui.navigation.Route.NewGroupDM +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.Route.NewGroupDM import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.Size55Modifier diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 79f6e64a2d..8f2e7fe071 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -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,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms -import android.R.attr.description import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.height @@ -33,9 +32,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -55,34 +52,40 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.NostrChannelDataSource.channel -import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteHasEvent +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.layouts.ChatHeaderLayout -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.BlankNote -import com.vitorpamplona.amethyst.ui.note.LoadChannel import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContentOrNull +import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures import com.vitorpamplona.amethyst.ui.note.ObserveDraftEvent import com.vitorpamplona.amethyst.ui.note.timeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.LoadEphemeralChatChannel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable 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.nip37Drafts.DraftEvent @Composable @@ -94,7 +97,7 @@ fun ChatroomHeaderCompose( if (baseNote.event != null) { ChatroomComposeChannelOrUser(baseNote, accountViewModel, nav) } else { - val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null) + val hasEvent by observeNoteHasEvent(baseNote, accountViewModel) if (hasEvent) { ChatroomComposeChannelOrUser(baseNote, accountViewModel, nav) } else { @@ -109,82 +112,71 @@ fun ChatroomComposeChannelOrUser( accountViewModel: AccountViewModel, nav: INav, ) { - if (baseNote.event is DraftEvent) { - ObserveDraftEvent(baseNote, accountViewModel) { - val channelHex by remember(it) { derivedStateOf { it.channelHex() } } - - if (channelHex != null) { - ChatroomChannel(channelHex!!, it, accountViewModel, nav) - } else { - ChatroomPrivateMessages(it, accountViewModel, nav) - } + val baseNoteEvent = baseNote.event + if (baseNoteEvent is DraftEvent) { + ObserveDraftEvent(baseNote, accountViewModel) { innerNote -> + ChatroomEntry(innerNote, accountViewModel, nav) } } else { - val channelHex by remember(baseNote) { derivedStateOf { baseNote.channelHex() } } - - if (channelHex != null) { - ChatroomChannel(channelHex!!, baseNote, accountViewModel, nav) - } else { - ChatroomPrivateMessages(baseNote, accountViewModel, nav) - } + ChatroomEntry(baseNote, accountViewModel, nav) } } @Composable -private fun ChatroomPrivateMessages( - baseNote: Note, +private fun ChatroomEntry( + lastMessage: Note, accountViewModel: AccountViewModel, nav: INav, ) { - val userRoom by - remember(baseNote) { - derivedStateOf { - (baseNote.event as? ChatroomKeyable)?.chatroomKey(accountViewModel.userProfile().pubkeyHex) + val baseNoteEvent = lastMessage.event + when (baseNoteEvent) { + is ChannelMessageEvent -> + baseNoteEvent.channelId()?.let { + LoadPublicChatChannel(it, accountViewModel) { channel -> + ChannelRoomCompose(lastMessage, channel, accountViewModel, nav) + } + } + is ChannelMetadataEvent -> + baseNoteEvent.channelId()?.let { + LoadPublicChatChannel(it, accountViewModel) { channel -> + ChannelRoomCompose(lastMessage, channel, accountViewModel, nav) + } + } + is ChannelCreateEvent -> + LoadPublicChatChannel(baseNoteEvent.id, accountViewModel) { channel -> + ChannelRoomCompose(lastMessage, channel, accountViewModel, nav) + } + is ChatroomKeyable -> { + val room = baseNoteEvent.chatroomKey(accountViewModel.userProfile().pubkeyHex) + UserRoomCompose(room, lastMessage, accountViewModel, nav) + } + is EphemeralChatEvent -> { + baseNoteEvent.roomId()?.let { + LoadEphemeralChatChannel(it, accountViewModel) { channel -> + ChannelRoomCompose(lastMessage, channel, accountViewModel, nav) + } } } - - CrossfadeIfEnabled(targetState = userRoom, label = "ChatroomPrivateMessages", accountViewModel = accountViewModel) { room -> - if (room != null) { - UserRoomCompose(baseNote, room, accountViewModel, nav) - } else { - BlankNote() - } - } -} - -@Composable -private fun ChatroomChannel( - channelHex: HexKey, - baseNote: Note, - accountViewModel: AccountViewModel, - nav: INav, -) { - LoadChannel(baseChannelHex = channelHex, accountViewModel) { channel -> - ChannelRoomCompose(baseNote, channel, accountViewModel, nav) + else -> BlankNote() } } @Composable private fun ChannelRoomCompose( - note: Note, - channel: Channel, + lastMessage: Note, + channel: PublicChatChannel, accountViewModel: AccountViewModel, nav: INav, ) { - val authorState by note.author!! - .live() - .metadata - .observeAsState() - val authorName = remember(note, authorState) { authorState?.user?.toBestDisplayName() } + val authorName by observeUserName(lastMessage.author!!, accountViewModel) + val channelState by observeChannel(channel, accountViewModel) - val channelState by channel.live.observeAsState() + val channel = channelState?.channel as? PublicChatChannel ?: return - val channelPicture = channelState?.channel?.profilePicture() ?: channel.profilePicture() - val channelName = channelState?.channel?.toBestDisplayName() ?: channel.toBestDisplayName() + val channelPicture = channel.profilePicture() + val channelName = channel.toBestDisplayName() - val noteEvent = note.event - - val route = Route.Channel(channel.idHex) + val noteEvent = lastMessage.event val description = if (noteEvent is ChannelCreateEvent) { @@ -200,22 +192,55 @@ private fun ChannelRoomCompose( ChannelName( channelIdHex = channel.idHex, channelPicture = channelPicture, - channelTitle = { modifier -> ChannelTitleWithLabelInfo(channelName, modifier) }, - channelLastTime = note.createdAt(), + channelTitle = { modifier -> ChannelTitleWithLabelInfo(channelName, R.string.public_chat, modifier) }, + channelLastTime = lastMessage.createdAt(), channelLastContent = "$authorName: $description", hasNewMessages = (noteEvent?.createdAt ?: Long.MIN_VALUE) > lastReadTime, loadProfilePicture = accountViewModel.settings.showProfilePictures.value, loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, - onClick = { nav.nav(route) }, + onClick = { nav.nav(routeFor(channel)) }, + ) +} + +@Composable +private fun ChannelRoomCompose( + lastMessage: Note, + channel: EphemeralChatChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val authorName by observeUserName(lastMessage.author!!, accountViewModel) + val channelState by observeChannel(channel, accountViewModel) + + val channel = channelState?.channel as? EphemeralChatChannel ?: return + + val relayInfo by loadRelayInfo(channel.roomId.relayUrl, accountViewModel) + + val noteEvent = lastMessage.event + val description = noteEvent?.content?.take(200) + + val lastReadTime by accountViewModel.account.loadLastReadFlow("Channel/${channel.roomId.toKey()}").collectAsStateWithLifecycle() + + ChannelName( + channelIdHex = channel.roomId.toKey(), + channelPicture = relayInfo.icon, + channelTitle = { modifier -> ChannelTitleWithLabelInfo(channel.toBestDisplayName(), R.string.ephemeral_relay_chat, modifier) }, + channelLastTime = lastMessage.createdAt(), + channelLastContent = "$authorName: $description", + hasNewMessages = (noteEvent?.createdAt ?: Long.MIN_VALUE) > lastReadTime, + loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, + onClick = { nav.nav(routeFor(channel)) }, ) } @Composable private fun ChannelTitleWithLabelInfo( channelName: String, + label: Int, modifier: Modifier, ) { - val label = stringRes(id = R.string.public_chat) + val label = stringRes(id = label) val placeHolderColor = MaterialTheme.colorScheme.placeholderText val channelNameAndBoostInfo = remember(channelName) { @@ -251,8 +276,8 @@ private fun ChannelTitleWithLabelInfo( @Composable private fun UserRoomCompose( - note: Note, room: ChatroomKey, + lastMessage: Note, accountViewModel: AccountViewModel, nav: INav, ) { @@ -266,10 +291,10 @@ private fun UserRoomCompose( }, firstRow = { RoomNameDisplay(room, Modifier.weight(1f), accountViewModel) - TimeAgo(note.createdAt()) + TimeAgo(lastMessage.createdAt()) }, secondRow = { - LoadDecryptedContentOrNull(note, accountViewModel) { content -> + LoadDecryptedContentOrNull(lastMessage, accountViewModel) { content -> if (content != null) { Text( content, @@ -291,11 +316,11 @@ private fun UserRoomCompose( } val lastReadTime by accountViewModel.account.loadLastReadFlow("Room/${room.hashCode()}").collectAsStateWithLifecycle() - if ((note.createdAt() ?: Long.MIN_VALUE) > lastReadTime) { + if ((lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime) { NewItemsBubble() } }, - onClick = { nav.nav(Route.Room(room.hashCode())) }, + onClick = { nav.nav(Route.Room(room)) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MessagesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MessagesScreen.kt index 1bd85d7ae9..000ab65f9b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MessagesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MessagesScreen.kt @@ -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,7 +25,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -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.amethyst.ui.screen.loggedIn.chats.rooms.singlepane.MessagesSinglePane import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.twopane.MessagesTwoPane diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt similarity index 56% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index 0d9622e374..a3773be358 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -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,13 +18,16 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.replace -import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEventIds +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent +import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent @@ -36,34 +39,45 @@ class ChatroomListKnownFeedFilter( // returns the last Note of each user. override fun feed(): List { - val me = account.userProfile() + val chatList = account.chatroomList val followingKeySet = account.followingKeySet() - val knownChatrooms = - me.privateChatrooms.filter { - (it.value.senderIntersects(followingKeySet) || me.hasSentMessagesTo(it.key)) && - !account.isAllHidden(it.key.users) - } - val privateMessages = - knownChatrooms.mapNotNull { it -> - it.value.roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).lastOrNull { - it.event != null + chatList.rooms.mapNotNull { key, chatroom -> + if ((chatroom.senderIntersects(followingKeySet) || chatList.hasSentMessagesTo(key)) && + !account.isAllHidden(key.users) + ) { + chatroom.lastMessage + } else { + null } } val publicChannels = account - .selectedChatsFollowList() - .mapNotNull { LocalCache.getChannelIfExists(it) } + .publicChatList.flow.value .mapNotNull { it -> - it.notes + LocalCache + .getOrCreatePublicChatChannel(it.eventId) + .notes .filter { key, it -> account.isAcceptable(it) && it.event != null } .sortedWith(DefaultFeedOrder) .firstOrNull() } - return (privateMessages + publicChannels).sortedWith(DefaultFeedOrder) + val ephemeralChats = + account + .ephemeralChatList.liveEphemeralChatList.value + .mapNotNull { it -> + LocalCache + .getOrCreateEphemeralChannel(it) + .notes + .filter { key, it -> account.isAcceptable(it) && it.event != null } + .sortedWith(DefaultFeedOrder) + .firstOrNull() + } + + return (privateMessages + publicChannels + ephemeralChats).sortedWith(DefaultFeedOrder) } override fun updateListWith( @@ -74,11 +88,12 @@ class ChatroomListKnownFeedFilter( // Gets the latest message by channel from the new items. val newRelevantPublicMessages = filterRelevantPublicMessages(newItems, account) + val newRelevantEphemeralChats = filterRelevantEphemeralChats(newItems, account) // Gets the latest message by room from the new items. val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account) - if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty()) { + if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty() && newRelevantEphemeralChats.isEmpty()) { return oldList } @@ -87,7 +102,24 @@ class ChatroomListKnownFeedFilter( newRelevantPublicMessages.forEach { newNotePair -> var hasUpdated = false oldList.forEach { oldNote -> - if (newNotePair.key == oldNote.channelHex()) { + val channelId = (oldNote.event as? ChannelMessageEvent)?.channelId() + if (newNotePair.key == channelId) { + hasUpdated = true + if ((newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)) { + myNewList = myNewList.replace(oldNote, newNotePair.value) + } + } + } + if (!hasUpdated) { + myNewList = myNewList.plus(newNotePair.value) + } + } + + newRelevantEphemeralChats.forEach { newNotePair -> + var hasUpdated = false + oldList.forEach { oldNote -> + val noteEvent = (oldNote.event as? EphemeralChatEvent)?.roomId() + if (newNotePair.key == noteEvent) { hasUpdated = true if ((newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)) { myNewList = myNewList.replace(oldNote, newNotePair.value) @@ -122,14 +154,15 @@ class ChatroomListKnownFeedFilter( override fun applyFilter(newItems: Set): Set { // Gets the latest message by channel from the new items. val newRelevantPublicMessages = filterRelevantPublicMessages(newItems, account) + val newRelevantEphemeralChats = filterRelevantEphemeralChats(newItems, account) // Gets the latest message by room from the new items. val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account) - return if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty()) { + return if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty() && newRelevantEphemeralChats.isEmpty()) { emptySet() } else { - (newRelevantPrivateMessages.values + newRelevantPublicMessages.values).toSet() + (newRelevantPrivateMessages.values + newRelevantPublicMessages.values + newRelevantEphemeralChats.values).toSet() } } @@ -137,25 +170,20 @@ class ChatroomListKnownFeedFilter( newItems: Set, account: Account, ): MutableMap { - val followingChannels = - account - .userProfile() - .latestContactList - ?.taggedEventIds() - ?.toSet() ?: emptySet() + val followingChannels = account.publicChatList.flowSet.value val newRelevantPublicMessages = mutableMapOf() newItems - .filter { it.event is ChannelMessageEvent } .forEach { newNote -> - newNote.channelHex()?.let { channelHex -> - if (channelHex in followingChannels && account.isAcceptable(newNote)) { - val lastNote = newRelevantPublicMessages.get(channelHex) + val channelId = (newNote.event as? ChannelMessageEvent)?.channelId() + if (channelId != null) { + if (channelId in followingChannels && account.isAcceptable(newNote)) { + val lastNote = newRelevantPublicMessages.get(channelId) if (lastNote != null) { if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) { - newRelevantPublicMessages.put(channelHex, newNote) + newRelevantPublicMessages.put(channelId, newNote) } } else { - newRelevantPublicMessages.put(channelHex, newNote) + newRelevantPublicMessages.put(channelId, newNote) } } } @@ -163,6 +191,32 @@ class ChatroomListKnownFeedFilter( return newRelevantPublicMessages } + private fun filterRelevantEphemeralChats( + newItems: Set, + account: Account, + ): MutableMap { + val followingEphemeralChats = account.ephemeralChatList.liveEphemeralChatList.value + val newRelevantEphemeralChats = mutableMapOf() + newItems + .forEach { newNote -> + val noteEvent = newNote.event as? EphemeralChatEvent + if (noteEvent != null) { + val room = noteEvent.roomId() + if (room != null && room in followingEphemeralChats && account.isAcceptable(newNote)) { + val lastNote = newRelevantEphemeralChats.get(room) + if (lastNote != null) { + if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) { + newRelevantEphemeralChats.put(room, newNote) + } + } else { + newRelevantEphemeralChats.put(room, newNote) + } + } + } + } + return newRelevantEphemeralChats + } + private fun filterRelevantPrivateMessages( newItems: Set, account: Account, @@ -172,27 +226,27 @@ class ChatroomListKnownFeedFilter( val newRelevantPrivateMessages = mutableMapOf() newItems - .filter { it.event is ChatroomKeyable } .forEach { newNote -> val roomKey = (newNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex) - val room = account.userProfile().privateChatrooms[roomKey] - - if (roomKey != null && room != null) { - if ( - ( - newNote.author?.pubkeyHex == me.pubkeyHex || - room.senderIntersects(followingKeySet) || - me.hasSentMessagesTo(roomKey) - ) && - !account.isAllHidden(roomKey.users) - ) { - val lastNote = newRelevantPrivateMessages.get(roomKey) - if (lastNote != null) { - if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) { + if (roomKey != null) { + val room = account.chatroomList.rooms.get(roomKey) + if (room != null) { + if ( + ( + newNote.author?.pubkeyHex == me.pubkeyHex || + room.senderIntersects(followingKeySet) || + account.chatroomList.hasSentMessagesTo(roomKey) + ) && + !account.isAllHidden(roomKey.users) + ) { + val lastNote = newRelevantPrivateMessages.get(roomKey) + if (lastNote != null) { + if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) { + newRelevantPrivateMessages.put(roomKey, newNote) + } + } else { newRelevantPrivateMessages.put(roomKey, newNote) } - } else { - newRelevantPrivateMessages.put(roomKey, newNote) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt similarity index 81% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt index 1746610ae8..969695f432 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt @@ -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,12 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.replace -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable @@ -34,20 +35,15 @@ class ChatroomListNewFeedFilter( // returns the last Note of each user. override fun feed(): List { - val me = account.userProfile() + val chatList = account.chatroomList val followingKeySet = account.followingKeySet() - val newChatrooms = - me.privateChatrooms.filter { - !it.value.senderIntersects(followingKeySet) && - !me.hasSentMessagesTo(it.key) && - !account.isAllHidden(it.key.users) - } - val privateMessages = - newChatrooms.mapNotNull { it -> - it.value.roomMessages.sortedWith(DefaultFeedOrder).firstOrNull { - it.event != null + chatList.rooms.mapNotNull { key, chatroom -> + if (!chatroom.senderIntersects(followingKeySet) && !chatList.hasSentMessagesTo(key) && !account.isAllHidden(key.users)) { + chatroom.lastMessage + } else { + null } } @@ -108,19 +104,17 @@ class ChatroomListNewFeedFilter( val followingKeySet = account.followingKeySet() val newRelevantPrivateMessages = mutableMapOf() - newItems - .filter { it.event is PrivateDmEvent } - .forEach { newNote -> - val roomKey = (newNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex) - val room = account.userProfile().privateChatrooms[roomKey] + newItems.forEach { newNote -> + val noteEvent = newNote.event + if (noteEvent is ChatroomKeyable) { + val roomKey = noteEvent.chatroomKey(me.pubkeyHex) + val room = account.chatroomList.rooms.get(roomKey) - if ( - roomKey != null && - room != null && + if (room != null && ( newNote.author?.pubkeyHex != me.pubkeyHex && room.senderIntersects(followingKeySet) && - !me.hasSentMessagesTo(roomKey) + !account.chatroomList.hasSentMessagesTo(roomKey) ) && !account.isAllHidden(roomKey.users) ) { @@ -134,6 +128,8 @@ class ChatroomListNewFeedFilter( } } } + } + return newRelevantPrivateMessages } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt new file mode 100644 index 0000000000..fa72099c3c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient + +// This allows multiple screen to be listening to tags, even the same tag +class ChatroomListState( + val account: Account, +) + +class ChatroomListFilterAssembler( + client: NostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + DMsFromUserFilterSubAssembler(client, ::allKeys), + FollowingPublicChatSubAssembler(client, ::allKeys), + FollowingEphemeralChatSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } + + override fun printStats() = group.forEach { it.printStats() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/WatchAccountForListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssemblerSubscription.kt similarity index 63% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/WatchAccountForListScreen.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssemblerSubscription.kt index c3f79437d2..9c041e2894 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/WatchAccountForListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssemblerSubscription.kt @@ -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,28 +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.ui.screen.loggedIn.chats.rooms +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource -import com.vitorpamplona.amethyst.ui.feeds.FeedContentState +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch @Composable -fun WatchAccountForListScreen( - knownFeedContentState: FeedContentState, - newFeedContentState: FeedContentState, +fun ChatroomListFilterAssemblerSubscription(accountViewModel: AccountViewModel) { + ChatroomListFilterAssemblerSubscription( + accountViewModel.dataSources().chatroomList, + accountViewModel, + ) +} + +@Composable +fun ChatroomListFilterAssemblerSubscription( + dataSource: ChatroomListFilterAssembler, accountViewModel: AccountViewModel, ) { - LaunchedEffect(accountViewModel) { - launch(Dispatchers.IO) { - NostrChatroomListDataSource.account = accountViewModel.account - NostrChatroomListDataSource.start() - knownFeedContentState.invalidateData(true) - newFeedContentState.invalidateData(true) + // different screens get different states + // even if they are tracking the same tag. + val state = + remember(accountViewModel.account) { + ChatroomListState(accountViewModel.account) } - } + + KeyDataSourceSubscription(state, dataSource) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt new file mode 100644 index 0000000000..8463cab688 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt @@ -0,0 +1,82 @@ +/** + * 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.ui.screen.loggedIn.chats.rooms.datasource + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +class DMsFromUserFilterSubAssembler( + client: NostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun updateFilter( + key: ChatroomListState, + since: SincePerRelayMap?, + ): List? = + key.account.outboxRelays.flow.value.map { + filterNip04DMsFromMe(key.account.userProfile(), it, since?.get(it)?.time) + } + + key.account.dmRelays.flow.value.map { + filterNip04DMsToMe(key.account.userProfile(), it, since?.get(it)?.time) + } + + override fun user(key: ChatroomListState) = key.account.userProfile() + + val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: ChatroomListState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.account.scope.launch(Dispatchers.Default) { + key.account.outboxRelays.flow.collectLatest { + invalidateFilters() + } + }, + key.account.scope.launch(Dispatchers.Default) { + key.account.dmRelays.flow.collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt new file mode 100644 index 0000000000..1c1c68f193 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource + +import com.vitorpamplona.amethyst.model.Constants +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent +import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.utils.mapOfSet + +fun filterFollowingEphemeralChats( + followingChannels: Set, + since: SincePerRelayMap?, +): List? { + if (followingChannels.isEmpty()) return null + + val relayRoomDTags = + mapOfSet { + followingChannels.forEach { room -> + if (!room.relayUrl.url.isBlank()) { + add(room.relayUrl, room.id.ifBlank { "_" }) + } else { + Constants.eventFinderRelays.forEach { + add(it, room.id.ifBlank { "_" }) + } + } + } + } + + return relayRoomDTags.map { + RelayBasedFilter( + // Metadata comes from any relay + relay = it.key, + filter = + Filter( + kinds = listOf(EphemeralChatEvent.KIND), + tags = mapOf("d" to it.value.sorted()), + limit = 100, + since = since?.get(it.key)?.time, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingPublicChats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingPublicChats.kt new file mode 100644 index 0000000000..e836d3d95b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingPublicChats.kt @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource + +import com.vitorpamplona.amethyst.model.Constants +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.utils.mapOfSet + +fun filterFollowingPublicChats( + followingChannels: Set, + since: SincePerRelayMap?, +): List? { + if (followingChannels.isEmpty()) return null + + val relayRoomDTags = + mapOfSet { + followingChannels.forEach { channelId -> + val relays = + LocalCache.getPublicChatChannelIfExists(channelId)?.relays() + ?: LocalCache.relayHints.hintsForEvent(channelId).ifEmpty { null } + ?: Constants.eventFinderRelays + + relays.forEach { relayUrl -> + add(relayUrl, channelId) + } + } + } + + return relayRoomDTags.map { + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = listOf(ChannelCreateEvent.KIND), + ids = it.value.sorted(), + since = since?.get(it.key)?.time, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterLastMessageFollowingPublicChats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterLastMessageFollowingPublicChats.kt new file mode 100644 index 0000000000..0c9c831418 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterLastMessageFollowingPublicChats.kt @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource + +import com.vitorpamplona.amethyst.model.Constants +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.utils.mapOfSet + +fun filterLastMessageFollowingPublicChats( + followingChannels: Set, + since: SincePerRelayMap?, +): List? { + if (followingChannels.isEmpty()) return null + + val relayRoomDTags = + mapOfSet { + followingChannels.forEach { channelId -> + val relays = + LocalCache.getPublicChatChannelIfExists(channelId)?.relays() + ?: LocalCache.relayHints.hintsForEvent(channelId).ifEmpty { null } + ?: Constants.eventFinderRelays + + relays.forEach { relayUrl -> + add(relayUrl, channelId) + } + } + } + + return relayRoomDTags + .map { + listOf( + RelayBasedFilter( + // Metadata comes from any relay + relay = it.key, + filter = + Filter( + kinds = listOf(ChannelMetadataEvent.KIND), + tags = mapOf("e" to it.value.sorted()), + since = since?.get(it.key)?.time, + limit = 1, + ), + ), + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = listOf(ChannelMessageEvent.KIND), + tags = mapOf("e" to it.value.sorted()), + since = since?.get(it.key)?.time, + // Remember to consider spam that is being removed from the UI + limit = 100, + ), + ), + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt new file mode 100644 index 0000000000..e32888b580 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt @@ -0,0 +1,42 @@ +/** + * 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.ui.screen.loggedIn.chats.rooms.datasource + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent + +fun filterNip04DMsFromMe( + user: User, + relay: NormalizedRelayUrl, + since: Long?, +): RelayBasedFilter = + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(PrivateDmEvent.KIND), + authors = listOf(user.pubkeyHex), + since = since, + ), + ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt new file mode 100644 index 0000000000..2488bed77f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt @@ -0,0 +1,42 @@ +/** + * 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.ui.screen.loggedIn.chats.rooms.datasource + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent + +fun filterNip04DMsToMe( + user: User, + relay: NormalizedRelayUrl, + since: Long?, +): RelayBasedFilter = + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(PrivateDmEvent.KIND), + tags = mapOf("p" to listOf(user.pubkeyHex)), + since = since, + ), + ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingEphemeralChatSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingEphemeralChatSubAssembler.kt new file mode 100644 index 0000000000..3c46a38764 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingEphemeralChatSubAssembler.kt @@ -0,0 +1,74 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch + +class FollowingEphemeralChatSubAssembler( + client: NostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun updateFilter( + key: ChatroomListState, + since: SincePerRelayMap?, + ): List? = + listOfNotNull( + filterFollowingEphemeralChats(key.account.ephemeralChatList.liveEphemeralChatList.value, since), + ).flatten() + + override fun user(key: ChatroomListState) = key.account.userProfile() + + val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: ChatroomListState): Subscription { + userJobMap[key.account.userProfile()]?.forEach { it.cancel() } + userJobMap[key.account.userProfile()] = + listOf( + key.account.scope.launch(Dispatchers.Default) { + key.account.ephemeralChatList.liveEphemeralChatList.sample(500).collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingPublicChatSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingPublicChatSubAssembler.kt new file mode 100644 index 0000000000..a59c0fdc5c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingPublicChatSubAssembler.kt @@ -0,0 +1,75 @@ +/** + * 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.ui.screen.loggedIn.chats.rooms.datasource + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch + +class FollowingPublicChatSubAssembler( + client: NostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun updateFilter( + key: ChatroomListState, + since: SincePerRelayMap?, + ): List? = + listOfNotNull( + filterLastMessageFollowingPublicChats(key.account.publicChatList.flowSet.value, since), + filterFollowingPublicChats(key.account.publicChatList.flowSet.value, since), + ).flatten() + + override fun user(key: ChatroomListState) = key.account.userProfile() + + val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: ChatroomListState): Subscription { + userJobMap[key.account.userProfile()]?.forEach { it.cancel() } + userJobMap[key.account.userProfile()] = + listOf( + key.account.scope.launch(Dispatchers.Default) { + key.account.publicChatList.flowSet.sample(500).collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt index b233450305..a43e09e9c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt @@ -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,12 +24,10 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -40,8 +38,8 @@ import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.FeedState import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox -import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChatroomHeaderCompose import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -50,21 +48,23 @@ import com.vitorpamplona.amethyst.ui.theme.FeedPadding @Composable fun ChatroomListFeedView( feedContentState: FeedContentState, + scrollStateKey: String, accountViewModel: AccountViewModel, nav: INav, - markAsRead: MutableState, ) { RefresheableBox(feedContentState, true) { - CrossFadeState(feedContentState, accountViewModel, nav, markAsRead) + SaveableFeedContentState(feedContentState, scrollStateKey) { listState -> + CrossFadeState(feedContentState, listState, accountViewModel, nav) + } } } @Composable private fun CrossFadeState( feedContentState: FeedContentState, + listState: LazyListState, accountViewModel: AccountViewModel, nav: INav, - markAsRead: MutableState, ) { val feedState by feedContentState.feedContent.collectAsStateWithLifecycle() @@ -81,7 +81,7 @@ private fun CrossFadeState( FeedError(state.errorMessage) { feedContentState.invalidateData() } } is FeedState.Loaded -> { - FeedLoaded(state, feedContentState, accountViewModel, nav, markAsRead) + FeedLoaded(state, listState, accountViewModel, nav) } FeedState.Loading -> { LoadingFeed() @@ -93,23 +93,12 @@ private fun CrossFadeState( @Composable private fun FeedLoaded( loaded: FeedState.Loaded, - feedContentState: FeedContentState, + listState: LazyListState, accountViewModel: AccountViewModel, nav: INav, - markAsRead: MutableState, ) { val items by loaded.feed.collectAsStateWithLifecycle() - val listState = rememberLazyListState() - - LaunchedEffect(key1 = markAsRead.value) { - if (markAsRead.value) { - accountViewModel.markAllAsRead(items.list, accountViewModel) { markAsRead.value = false } - } - } - - WatchScrollToTop(feedContentState, listState) - LazyColumn( contentPadding = FeedPadding, state = listState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt index d11c2d114d..15a8a63713 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt @@ -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 @@ -38,7 +38,6 @@ import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable -import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -49,7 +48,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.feeds.FeedContentState -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.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size40dp @@ -60,8 +59,8 @@ import kotlinx.coroutines.launch @Immutable class MessagesTabItem( val resource: Int, + val scrollStateKey: String, val feedContentState: FeedContentState, - val markAsRead: MutableState, ) @Composable @@ -128,9 +127,9 @@ fun MessagesPager( ) { page -> ChatroomListFeedView( feedContentState = tabs[page].feedContentState, + scrollStateKey = tabs[page].scrollStateKey, accountViewModel = accountViewModel, nav = nav, - markAsRead = tabs[page].markAsRead, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt index 4384893bda..6503d9f858 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt @@ -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,19 +25,20 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.MainTopBar -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.AmethystClickableIcon +import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelFabColumn -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.WatchAccountForListScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.WatchLifecycleAndRefreshDataSource +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed.MessagesPager import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed.MessagesTabHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed.MessagesTabItem @@ -51,18 +52,17 @@ fun MessagesSinglePane( ) { val pagerState = rememberPagerState { 2 } - val markKnownAsRead = remember { mutableStateOf(false) } - val markNewAsRead = remember { mutableStateOf(false) } + WatchLifecycleAndUpdateModel(knownFeedContentState) + WatchLifecycleAndUpdateModel(newFeedContentState) - WatchAccountForListScreen(knownFeedContentState, newFeedContentState, accountViewModel) - WatchLifecycleAndRefreshDataSource(accountViewModel) + ChatroomListFilterAssemblerSubscription(accountViewModel) val tabs by - remember(knownFeedContentState, markKnownAsRead) { + remember(knownFeedContentState) { derivedStateOf { listOf( - MessagesTabItem(R.string.known, knownFeedContentState, markKnownAsRead), - MessagesTabItem(R.string.new_requests, newFeedContentState, markNewAsRead), + MessagesTabItem(R.string.known, ScrollStateKeys.MESSAGES_KNOWN, knownFeedContentState), + MessagesTabItem(R.string.new_requests, ScrollStateKeys.MESSAGES_NEW, newFeedContentState), ) } } @@ -71,12 +71,12 @@ fun MessagesSinglePane( isInvertedLayout = false, topBar = { Column { - MainTopBar(accountViewModel, nav) + UserDrawerSearchTopBar(accountViewModel, nav) { AmethystClickableIcon() } MessagesTabHeader( pagerState, tabs, - { markKnownAsRead.value = true }, - { markNewAsRead.value = true }, + { accountViewModel.markAllChatNotesAsRead(knownFeedContentState.visibleNotes()) }, + { accountViewModel.markAllChatNotesAsRead(newFeedContentState.visibleNotes()) }, ) } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/ChatroomListPane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/ChatroomListPane.kt index 5bd153c533..0525c2bf99 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/ChatroomListPane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/ChatroomListPane.kt @@ -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,15 +26,15 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.WatchAccountForListScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.WatchLifecycleAndRefreshDataSource +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed.MessagesPager import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed.MessagesTabHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed.MessagesTabItem @@ -48,18 +48,17 @@ fun ChatroomList( ) { val pagerState = rememberPagerState { 2 } - val markKnownAsRead = remember { mutableStateOf(false) } - val markNewAsRead = remember { mutableStateOf(false) } + WatchLifecycleAndUpdateModel(knownFeedContentState) + WatchLifecycleAndUpdateModel(newFeedContentState) - WatchAccountForListScreen(knownFeedContentState, newFeedContentState, accountViewModel) - WatchLifecycleAndRefreshDataSource(accountViewModel) + ChatroomListFilterAssemblerSubscription(accountViewModel) val tabs by - remember(knownFeedContentState, markKnownAsRead) { + remember(knownFeedContentState) { derivedStateOf { listOf( - MessagesTabItem(R.string.known, knownFeedContentState, markKnownAsRead), - MessagesTabItem(R.string.new_requests, newFeedContentState, markNewAsRead), + MessagesTabItem(R.string.known, ScrollStateKeys.MESSAGES_KNOWN, knownFeedContentState), + MessagesTabItem(R.string.new_requests, ScrollStateKeys.MESSAGES_NEW, newFeedContentState), ) } } @@ -68,8 +67,8 @@ fun ChatroomList( MessagesTabHeader( pagerState, tabs, - { markKnownAsRead.value = true }, - { markNewAsRead.value = true }, + { accountViewModel.markAllChatNotesAsRead(knownFeedContentState.visibleNotes()) }, + { accountViewModel.markAllChatNotesAsRead(newFeedContentState.visibleNotes()) }, ) MessagesPager( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt index 7c126bd16a..c3048c143d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt @@ -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,7 +21,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.twopane import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -36,17 +35,17 @@ import com.google.accompanist.adaptive.FoldAwareConfiguration import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy import com.google.accompanist.adaptive.TwoPane import com.vitorpamplona.amethyst.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.MainTopBar -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.AmethystClickableIcon +import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.Chatroom -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ChannelView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelView import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelFabColumn import com.vitorpamplona.amethyst.ui.theme.Size20dp -import kotlinx.coroutines.channels.Channel @Composable fun MessagesTwoPane( @@ -63,22 +62,16 @@ fun MessagesTwoPane( val strategy = remember { if (widthSizeClass == WindowWidthSizeClass.Expanded) { - HorizontalTwoPaneStrategy( - splitFraction = 1f / 3f, - ) + HorizontalTwoPaneStrategy(splitFraction = 1f / 3f) } else { - HorizontalTwoPaneStrategy( - splitFraction = 1f / 2.5f, - ) + HorizontalTwoPaneStrategy(splitFraction = 1f / 2.5f) } } DisappearingScaffold( isInvertedLayout = false, topBar = { - Column { - MainTopBar(accountViewModel, nav) - } + UserDrawerSearchTopBar(accountViewModel, nav) { AmethystClickableIcon() } }, bottomBar = { AppBottomBar(Route.Message, accountViewModel) { route -> @@ -111,15 +104,18 @@ fun MessagesTwoPane( Box(Modifier.fillMaxSize().systemBarsPadding()) { twoPaneNav.innerNav.value?.let { if (it is Route.Room) { - Chatroom( - roomId = it.id.toString(), + ChatroomView( + room = it.toKey(), accountViewModel = accountViewModel, + draftMessage = it.message, + replyToNote = it.replyId, + editFromDraft = it.draftId, nav = nav, ) } - if (it is Route.Channel) { - ChannelView( + if (it is Route.PublicChatChannel) { + PublicChatChannelView( channelId = it.id, accountViewModel = accountViewModel, nav = nav, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/TwoPaneNav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/TwoPaneNav.kt index 8b9ae66fe9..214a74fc59 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/TwoPaneNav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/TwoPaneNav.kt @@ -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,37 +22,37 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.twopane import androidx.compose.material3.DrawerState import androidx.compose.runtime.mutableStateOf -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav.nav -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlin.reflect.KClass class TwoPaneNav( val nav: INav, - val scope: CoroutineScope, + override val scope: CoroutineScope, ) : INav { override val drawerState: DrawerState = nav.drawerState val innerNav = mutableStateOf(null) override fun nav(route: Route) { - if (route is Route.Room || route is Route.Channel) { + if (route is Route.Room || route is Route.PublicChatChannel) { innerNav.value = route } else { nav.nav(route) } } - override fun nav(routeMaker: suspend () -> Route) { - scope.launch(Dispatchers.Default) { - val route = routeMaker() - if (route is Route.Room || route is Route.Channel) { - innerNav.value = route - } else { - nav.nav(route) + override fun nav(computeRoute: suspend () -> Route?) { + scope.launch { + val route = computeRoute() + if (route != null) { + if (route is Route.Room || route is Route.PublicChatChannel) { + innerNav.value = route + } else { + nav.nav(route) + } } } } @@ -67,9 +67,9 @@ class TwoPaneNav( override fun popUpTo( route: Route, - upToClass: KClass, + klass: KClass, ) { - nav.popUpTo(route, upToClass) + nav.popUpTo(route, klass) } override fun closeDrawer() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt index 2a22348dc0..668878e1d5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt @@ -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 @@ -41,7 +41,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -59,9 +58,9 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.TitleIconModifier -import com.vitorpamplona.amethyst.ui.navigation.rememberHeightDecreaser +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar +import com.vitorpamplona.amethyst.ui.navigation.topbars.TitleIconModifier import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.SettingSwitchItem import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -97,9 +96,7 @@ fun ChatFileUploadDialog( SetDialogToEdgeToEdge() Scaffold( topBar = { - TopAppBar( - scrollBehavior = rememberHeightDecreaser(), - modifier = Modifier, + ShorterTopAppBar( title = title, navigationIcon = { IconButton( @@ -148,7 +145,8 @@ private fun ImageVideoPostChat( fileUploadState: ChatFileUploadState, accountViewModel: AccountViewModel, ) { - val fileServers by accountViewModel.account.liveServerList.collectAsState() + val fileServers by accountViewModel.account.serverLists.liveServerList + .collectAsState() val fileServerOptions = remember(fileServers) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt index 1252f75974..3cf6b774e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/DisplayReplyingToNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/DisplayReplyingToNote.kt index b8dbaa4bd3..cecbca8e02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/DisplayReplyingToNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/DisplayReplyingToNote.kt @@ -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 @@ -37,7 +37,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.model.Note -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.amethyst.ui.screen.loggedIn.chats.feed.ChatroomMessageCompose import com.vitorpamplona.amethyst.ui.theme.Size20Modifier diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ThinSendButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ThinSendButton.kt index f3a0d42cee..d210059d70 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ThinSendButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ThinSendButton.kt @@ -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,7 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Send +import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable @@ -42,7 +42,7 @@ fun ThinSendButton( onClick = onClick, ) { Icon( - imageVector = Icons.Default.Send, + imageVector = Icons.AutoMirrored.Filled.Send, contentDescription = stringRes(id = R.string.accessibility_send), modifier = Size20Modifier, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/CommunityScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/CommunityScreen.kt index f0d51ba408..f36fbf65cf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/CommunityScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/CommunityScreen.kt @@ -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,35 +27,30 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.service.NostrCommunityDataSource -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.types.LongCommunityHeader import com.vitorpamplona.amethyst.ui.note.types.ShortCommunityHeader -import com.vitorpamplona.amethyst.ui.screen.NostrCommunityFeedViewModel import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.dal.CommunityFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource.CommunityFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address @Composable fun CommunityScreen( - aTagHex: String?, + aTagHex: Address, accountViewModel: AccountViewModel, nav: INav, ) { - if (aTagHex == null) return - - LoadAddressableNote(aTagHex = aTagHex, accountViewModel) { + LoadAddressableNote(aTagHex, accountViewModel) { it?.let { PrepareViewModelsCommunityScreen( note = it, @@ -72,11 +67,11 @@ fun PrepareViewModelsCommunityScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val followsFeedViewModel: NostrCommunityFeedViewModel = + val followsFeedViewModel: CommunityFeedViewModel = viewModel( key = note.idHex + "CommunityFeedViewModel", factory = - NostrCommunityFeedViewModel.Factory( + CommunityFeedViewModel.Factory( note, accountViewModel.account, ), @@ -88,39 +83,17 @@ fun PrepareViewModelsCommunityScreen( @Composable fun CommunityScreen( note: AddressableNote, - feedViewModel: NostrCommunityFeedViewModel, + feedViewModel: CommunityFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { - val lifeCycleOwner = LocalLifecycleOwner.current - - NostrCommunityDataSource.loadCommunity(note) - - LaunchedEffect(note) { feedViewModel.invalidateData() } - - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Community Start") - NostrCommunityDataSource.start() - feedViewModel.invalidateData() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Community Stop") - NostrCommunityDataSource.loadCommunity(null) - NostrCommunityDataSource.stop() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } + WatchLifecycleAndUpdateModel(feedViewModel) + CommunityFilterAssemblerSubscription(note, accountViewModel.dataSources().community) DisappearingScaffold( isInvertedLayout = false, topBar = { - CommunityTopBar(note.idHex, accountViewModel, nav) + CommunityTopBar(note.address, accountViewModel, nav) }, floatingButton = { NewCommunityNoteButton(note.idHex, accountViewModel, nav) @@ -142,11 +115,11 @@ fun CommunityScreen( @Composable fun CommunityTopBar( - id: String, + id: Address, accountViewModel: AccountViewModel, nav: INav, ) { - LoadAddressableNote(aTagHex = id, accountViewModel) { baseNote -> + LoadAddressableNote(id, accountViewModel) { baseNote -> if (baseNote != null) { TopBarExtensibleWithBackButton( title = { ShortCommunityHeader(baseNote, accountViewModel, nav) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/NewCommunityNoteButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/NewCommunityNoteButton.kt index 10d9dece83..158eab186c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/NewCommunityNoteButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/NewCommunityNoteButton.kt @@ -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,23 +20,21 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities -import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.LoadNote -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size26Modifier import com.vitorpamplona.amethyst.ui.theme.Size55Modifier @Composable @@ -69,9 +67,9 @@ fun NewCommunityNoteButton( containerColor = MaterialTheme.colorScheme.primary, ) { Icon( - painter = painterResource(R.drawable.ic_compose), + painter = painterRes(R.drawable.ic_compose, 2), contentDescription = stringRes(id = R.string.new_community_note), - modifier = Modifier.size(26.dp), + modifier = Size26Modifier, tint = Color.White, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/CommunityFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityFeedFilter.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/CommunityFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityFeedFilter.kt index f8108d057b..c279994c72 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/CommunityFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityFeedFilter.kt @@ -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,12 +18,14 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNote import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityFeedViewModel.kt new file mode 100644 index 0000000000..abd71e4ae4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityFeedViewModel.kt @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.dal + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.ui.screen.FeedViewModel + +class CommunityFeedViewModel( + val note: AddressableNote, + val account: Account, +) : FeedViewModel(CommunityFeedFilter(note, account)) { + class Factory( + val note: AddressableNote, + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = CommunityFeedViewModel(note, account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFeedFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFeedFilterSubAssembler.kt new file mode 100644 index 0000000000..bd7db1129b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFeedFilterSubAssembler.kt @@ -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.ui.screen.loggedIn.communities.datasource + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent + +class CommunityFeedFilterSubAssembler( + client: NostrClient, + allKeys: () -> Set, +) : SingleSubEoseManager(client, allKeys) { + override fun updateFilter( + keys: List, + since: SincePerRelayMap?, + ): List { + if (keys.isEmpty()) return emptyList() + + return keys.flatMap { + val commEvent = it.community.event + if (commEvent is CommunityDefinitionEvent) { + val relays = + commEvent.relayUrls().ifEmpty { null } + ?: LocalCache.relayHints.hintsForAddress(commEvent.addressTag()).ifEmpty { null } + ?: it.community.relayUrls() + + relays.toSet().map { + filterCommunityPosts(it, commEvent, since?.get(it)?.time) + } + } else { + emptyList() + } + } + } + + override fun distinct(key: CommunityQueryState) = key.community.idHex +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssembler.kt new file mode 100644 index 0000000000..e69a2dad92 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssembler.kt @@ -0,0 +1,47 @@ +/** + * 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.ui.screen.loggedIn.communities.datasource + +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient + +// This allows multiple screen to be listening to tags, even the same tag +class CommunityQueryState( + var community: AddressableNote, +) + +class CommunityFilterAssembler( + client: NostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + CommunityFeedFilterSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } + + override fun printStats() = group.forEach { it.printStats() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..ed4f4f3f1e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssemblerSubscription.kt @@ -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.ui.screen.loggedIn.communities.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription + +@Composable +fun CommunityFilterAssemblerSubscription( + channel: AddressableNote, + filterAssembler: CommunityFilterAssembler, +) { + // different screens get different states + // even if they are tracking the same tag. + val state = + remember(channel) { + CommunityQueryState(channel) + } + + KeyDataSourceSubscription(state, filterAssembler) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/FilterCommunityPosts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/FilterCommunityPosts.kt new file mode 100644 index 0000000000..c728a95267 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/FilterCommunityPosts.kt @@ -0,0 +1,44 @@ +/** + * 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.ui.screen.loggedIn.communities.datasource + +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent + +fun filterCommunityPosts( + relay: NormalizedRelayUrl, + community: CommunityDefinitionEvent, + since: Long?, +): RelayBasedFilter = + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = community.moderators().map { it.pubKey }.plus(community.pubKey), + tags = mapOf("a" to listOf(community.addressTag())), + kinds = CommunityPostApprovalEvent.KIND_LIST, + limit = 500, + since = since, + ), + ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/ChannelCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/ChannelCardCompose.kt new file mode 100644 index 0000000000..2f87c20798 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/ChannelCardCompose.kt @@ -0,0 +1,211 @@ +/** + * 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.ui.screen.loggedIn.discover + +import androidx.compose.foundation.layout.Column +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.model.Note +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.CheckHiddenFeedWatchBlockAndReport +import com.vitorpamplona.amethyst.ui.note.ClickableNote +import com.vitorpamplona.amethyst.ui.note.LongPressToQuickAction +import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent +import com.vitorpamplona.amethyst.ui.note.calculateBackgroundColor +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.RenderLongFormThumb +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.RenderPublicChatChannelThumb +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.RenderFollowSetThumb +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.RenderLiveActivityThumb +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.RenderCommunitiesThumb +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.RenderContentDVMThumb +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.RenderClassifiedsThumb +import com.vitorpamplona.amethyst.ui.theme.HalfPadding +import com.vitorpamplona.amethyst.ui.theme.StdPadding +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent + +@Composable +fun ChannelCardCompose( + baseNote: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + parentBackgroundColor: MutableState? = null, + forceEventKind: Int?, + isHiddenFeed: Boolean = false, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel) { + if (forceEventKind == null || baseNote.event?.kind == forceEventKind) { + CheckHiddenFeedWatchBlockAndReport( + note = baseNote, + modifier = modifier, + ignoreAllBlocksAndReports = isHiddenFeed, + showHiddenWarning = false, + accountViewModel = accountViewModel, + nav = nav, + ) { canPreview -> + NormalChannelCard( + baseNote = baseNote, + routeForLastRead = routeForLastRead, + modifier = modifier, + parentBackgroundColor = parentBackgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } +} + +@Composable +fun NormalChannelCard( + baseNote: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + nav: INav, +) { + LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup -> + CheckNewAndRenderChannelCard( + baseNote, + routeForLastRead, + modifier, + parentBackgroundColor, + accountViewModel, + showPopup, + nav, + ) + } +} + +@Composable +private fun CheckNewAndRenderChannelCard( + baseNote: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + showPopup: () -> Unit, + nav: INav, +) { + val backgroundColor = + calculateBackgroundColor( + createdAt = baseNote.createdAt(), + routeForLastRead = routeForLastRead, + parentBackgroundColor = parentBackgroundColor, + accountViewModel = accountViewModel, + ) + + ClickableNote( + baseNote = baseNote, + backgroundColor = backgroundColor, + modifier = modifier, + accountViewModel = accountViewModel, + showPopup = showPopup, + nav = nav, + ) { + InnerChannelCardWithReactions( + baseNote = baseNote, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +@Composable +fun InnerChannelCardWithReactions( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + when (baseNote.event) { + is LiveActivitiesEvent -> InnerCardRow(baseNote, accountViewModel, nav) + is CommunityDefinitionEvent -> InnerCardRow(baseNote, accountViewModel, nav) + is ChannelCreateEvent -> InnerCardRow(baseNote, accountViewModel, nav) + is ClassifiedsEvent -> InnerCardBox(baseNote, accountViewModel, nav) + is AppDefinitionEvent -> InnerCardRow(baseNote, accountViewModel, nav) + is FollowListEvent -> InnerCardRow(baseNote, accountViewModel, nav) + is LongTextNoteEvent -> InnerCardRow(baseNote, accountViewModel, nav) + } +} + +@Composable +fun InnerCardRow( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + Column(StdPadding) { + SensitivityWarning( + note = baseNote, + accountViewModel = accountViewModel, + ) { + RenderNoteRow( + baseNote, + accountViewModel, + nav, + ) + } + } +} + +@Composable +fun InnerCardBox( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + Column(HalfPadding) { + SensitivityWarning( + note = baseNote, + accountViewModel = accountViewModel, + ) { + RenderClassifiedsThumb(baseNote, accountViewModel, nav) + } + } +} + +@Composable +private fun RenderNoteRow( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + when (baseNote.event) { + is LiveActivitiesEvent -> RenderLiveActivityThumb(baseNote, accountViewModel, nav) + is CommunityDefinitionEvent -> RenderCommunitiesThumb(baseNote, accountViewModel, nav) + is ChannelCreateEvent -> RenderPublicChatChannelThumb(baseNote, accountViewModel, nav) + is AppDefinitionEvent -> RenderContentDVMThumb(baseNote, accountViewModel, nav) + is FollowListEvent -> RenderFollowSetThumb(baseNote, accountViewModel, nav) + is LongTextNoteEvent -> RenderLongFormThumb(baseNote, accountViewModel, nav) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt index 1d876a1b40..dfccfe8183 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt @@ -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,13 +34,17 @@ import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -48,13 +52,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.service.NostrDiscoveryDataSource import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty @@ -66,19 +66,24 @@ import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState import com.vitorpamplona.amethyst.ui.feeds.SaveableGridFeedContentState import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState -import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route -import com.vitorpamplona.amethyst.ui.note.ChannelCardCompose +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.TabItem import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.Size26Modifier +import com.vitorpamplona.amethyst.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.ui.theme.TabRowHeight +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent @@ -93,6 +98,8 @@ fun DiscoverScreen( nav: INav, ) { DiscoverScreen( + discoveryFollowSetsFeedContentState = accountViewModel.feedStates.discoverFollowSets, + discoveryReadsFeedContentState = accountViewModel.feedStates.discoverReads, discoveryContentNIP89FeedContentState = accountViewModel.feedStates.discoverDVMs, discoveryMarketplaceFeedContentState = accountViewModel.feedStates.discoverMarketplace, discoveryLiveFeedContentState = accountViewModel.feedStates.discoverLive, @@ -106,6 +113,8 @@ fun DiscoverScreen( @OptIn(ExperimentalFoundationApi::class) @Composable fun DiscoverScreen( + discoveryFollowSetsFeedContentState: FeedContentState, + discoveryReadsFeedContentState: FeedContentState, discoveryContentNIP89FeedContentState: FeedContentState, discoveryMarketplaceFeedContentState: FeedContentState, discoveryLiveFeedContentState: FeedContentState, @@ -114,34 +123,40 @@ fun DiscoverScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val lifeCycleOwner = LocalLifecycleOwner.current - val tabs by - remember( - discoveryContentNIP89FeedContentState, - discoveryLiveFeedContentState, - discoveryCommunityFeedContentState, - discoveryChatFeedContentState, - discoveryMarketplaceFeedContentState, - ) { + remember(accountViewModel) { mutableStateOf( listOf( TabItem( - R.string.discover_content, + R.string.discover_follows, + discoveryFollowSetsFeedContentState, + "DiscoverFollowSets", + ScrollStateKeys.DISCOVER_FOLLOWS, + FollowListEvent.KIND, + ), + TabItem( + R.string.discover_reads, + discoveryReadsFeedContentState, + "DiscoverReads", + ScrollStateKeys.DISCOVER_READS, + LongTextNoteEvent.KIND, + ), + TabItem( + R.string.discover_content_v2, discoveryContentNIP89FeedContentState, "DiscoverDiscoverContent", ScrollStateKeys.DISCOVER_CONTENT, AppDefinitionEvent.KIND, ), TabItem( - R.string.discover_live, + R.string.discover_live_v2, discoveryLiveFeedContentState, "DiscoverLive", ScrollStateKeys.DISCOVER_LIVE, LiveActivitiesEvent.KIND, ), TabItem( - R.string.discover_community, + R.string.discover_community_v2, discoveryCommunityFeedContentState, "DiscoverCommunity", ScrollStateKeys.DISCOVER_COMMUNITY, @@ -169,6 +184,8 @@ fun DiscoverScreen( val pagerState = rememberForeverPagerState(key = PagerStateKeys.DISCOVER_SCREEN) { tabs.size } WatchAccountForDiscoveryScreen( + discoveryFollowSetsFeedContentState = discoveryFollowSetsFeedContentState, + discoveryReadsFeedContentState = discoveryReadsFeedContentState, discoveryContentNIP89FeedContentState = discoveryContentNIP89FeedContentState, discoveryMarketplaceFeedContentState = discoveryMarketplaceFeedContentState, discoveryLiveFeedContentState = discoveryLiveFeedContentState, @@ -177,22 +194,15 @@ fun DiscoverScreen( accountViewModel = accountViewModel, ) - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Discovery Start") - NostrDiscoveryDataSource.start() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Discovery Stop") - NostrDiscoveryDataSource.stop() - } - } + WatchLifecycleAndUpdateModel(discoveryFollowSetsFeedContentState) + WatchLifecycleAndUpdateModel(discoveryReadsFeedContentState) + WatchLifecycleAndUpdateModel(discoveryContentNIP89FeedContentState) + WatchLifecycleAndUpdateModel(discoveryMarketplaceFeedContentState) + WatchLifecycleAndUpdateModel(discoveryLiveFeedContentState) + WatchLifecycleAndUpdateModel(discoveryCommunityFeedContentState) + WatchLifecycleAndUpdateModel(discoveryChatFeedContentState) - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } + DiscoveryFilterAssemblerSubscription(accountViewModel.dataSources().discovery, accountViewModel) DiscoverPages(pagerState, tabs, accountViewModel, nav) } @@ -237,6 +247,11 @@ private fun DiscoverPages( } } }, + floatingButton = { + if (tabs[pagerState.currentPage].resource == R.string.discover_marketplace) { + NewProductButton(accountViewModel, nav) + } + }, accountViewModel = accountViewModel, ) { HorizontalPager(state = pagerState, contentPadding = it) { page -> @@ -310,6 +325,28 @@ private fun RenderDiscoverFeed( } } +@Composable +fun NewProductButton( + accountViewModel: AccountViewModel, + nav: INav, +) { + FloatingActionButton( + onClick = { + nav.nav(Route.NewProduct()) + }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = stringRes(id = R.string.new_product), + modifier = Size26Modifier, + tint = Color.White, + ) + } +} + @Composable private fun RenderDiscoverFeed( feedContentState: FeedContentState, @@ -353,6 +390,8 @@ private fun RenderDiscoverFeed( @Composable fun WatchAccountForDiscoveryScreen( + discoveryFollowSetsFeedContentState: FeedContentState, + discoveryReadsFeedContentState: FeedContentState, discoveryContentNIP89FeedContentState: FeedContentState, discoveryMarketplaceFeedContentState: FeedContentState, discoveryLiveFeedContentState: FeedContentState, @@ -363,7 +402,8 @@ fun WatchAccountForDiscoveryScreen( val listState by accountViewModel.account.liveDiscoveryFollowLists.collectAsStateWithLifecycle() LaunchedEffect(accountViewModel, listState) { - NostrDiscoveryDataSource.resetFilters() + discoveryFollowSetsFeedContentState.checkKeysInvalidateDataAndSendToTop() + discoveryReadsFeedContentState.checkKeysInvalidateDataAndSendToTop() discoveryContentNIP89FeedContentState.checkKeysInvalidateDataAndSendToTop() discoveryMarketplaceFeedContentState.checkKeysInvalidateDataAndSendToTop() discoveryLiveFeedContentState.checkKeysInvalidateDataAndSendToTop() @@ -389,9 +429,7 @@ private fun DiscoverFeedLoaded( state = listState, ) { itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item -> - val defaultModifier = remember { Modifier.fillMaxWidth().animateItemPlacement() } - - Row(defaultModifier) { + Row(Modifier.fillMaxWidth().animateItem()) { ChannelCardCompose( baseNote = item, routeForLastRead = routeForLastRead, @@ -427,9 +465,7 @@ private fun DiscoverFeedColumnsLoaded( state = listState, ) { itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item -> - val defaultModifier = remember { Modifier.fillMaxWidth().animateItemPlacement() } - - Row(defaultModifier) { + Row(Modifier.fillMaxWidth().animateItem()) { ChannelCardCompose( baseNote = item, routeForLastRead = routeForLastRead, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoveryTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoveryTopBar.kt index eb5676e383..2101fe7b6b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoveryTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoveryTopBar.kt @@ -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,25 +23,47 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.ui.navigation.FollowListWithoutRoutes -import com.vitorpamplona.amethyst.ui.navigation.GenericMainTopBar -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner +import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar +import com.vitorpamplona.amethyst.ui.screen.FeedDefinition +import com.vitorpamplona.amethyst.ui.screen.FollowListState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes @Composable fun DiscoveryTopBar( accountViewModel: AccountViewModel, nav: INav, ) { - GenericMainTopBar(accountViewModel, nav) { + UserDrawerSearchTopBar(accountViewModel, nav) { val list by accountViewModel.account.settings.defaultDiscoveryFollowList .collectAsStateWithLifecycle() - FollowListWithoutRoutes( + FollowList( followListsModel = accountViewModel.feedStates.feedListOptions, listName = list, - ) { listName -> - accountViewModel.account.settings.changeDefaultDiscoveryFollowList(listName.code) - } + accountViewModel = accountViewModel, + onChange = accountViewModel.account.settings::changeDefaultDiscoveryFollowList, + ) } } + +@Composable +private fun FollowList( + followListsModel: FollowListState, + listName: String, + accountViewModel: AccountViewModel, + onChange: (FeedDefinition) -> Unit, +) { + val allLists by followListsModel.kind3GlobalPeople.collectAsStateWithLifecycle() + + FeedFilterSpinner( + placeholderCode = listName, + explainer = stringRes(R.string.select_list_to_filter), + options = allLists, + onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.kind3Follow) }, + accountViewModel = accountViewModel, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssembler.kt new file mode 100644 index 0000000000..a8d7a427ba --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssembler.kt @@ -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.ui.screen.loggedIn.discover.datasource + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import kotlinx.coroutines.CoroutineScope + +// This allows multiple screen to be listening to tags, even the same tag +class DiscoveryQueryState( + val account: Account, + val feedStates: AccountFeedContentStates, + val scope: CoroutineScope, +) + +class DiscoveryFilterAssembler( + client: NostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + DiscoveryLongFormClassifiedsAndDVMSubAssembler1(client, ::allKeys), + DiscoveryFollowsSetsAndLiveStreamsSubAssembler2(client, ::allKeys), + DiscoveryPublicChatsAndCommunitiesSubAssembler3(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } + + override fun printStats() = group.forEach { it.printStats() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..5a2a10b0c6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssemblerSubscription.kt @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun DiscoveryFilterAssemblerSubscription(accountViewModel: AccountViewModel) { + DiscoveryFilterAssemblerSubscription( + accountViewModel.dataSources().discovery, + accountViewModel, + ) +} + +@Composable +fun DiscoveryFilterAssemblerSubscription( + dataSource: DiscoveryFilterAssembler, + accountViewModel: AccountViewModel, +) { + // different screens get different states + // even if they are tracking the same tag. + val state = + remember(accountViewModel.account) { + DiscoveryQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope) + } + + KeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsSetsAndLiveStreamsSubAssembler2.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsSetsAndLiveStreamsSubAssembler2.kt new file mode 100644 index 0000000000..37c2242449 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsSetsAndLiveStreamsSubAssembler2.kt @@ -0,0 +1,105 @@ +/** + * 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.ui.screen.loggedIn.discover.datasource + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.makeFollowSetsFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.makeLiveActivitiesFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch + +class DiscoveryFollowsSetsAndLiveStreamsSubAssembler2( + client: NostrClient, + allKeys: () -> Set, +) : PerUserAndFollowListEoseManager(client, allKeys) { + override fun updateFilter( + key: DiscoveryQueryState, + since: SincePerRelayMap?, + ): List? { + val feedSettings = key.followsPerRelay() + + return makeFollowSetsFilter(feedSettings, since, key.feedStates.discoverFollowSets.lastNoteCreatedAtIfFilled()) + + makeLiveActivitiesFilter(feedSettings, since, key.feedStates.discoverLive.lastNoteCreatedAtIfFilled()) + } + + override fun user(key: DiscoveryQueryState) = key.account.userProfile() + + override fun list(key: DiscoveryQueryState) = key.listName() + + fun DiscoveryQueryState.listNameFlow() = account.settings.defaultDiscoveryFollowList + + fun DiscoveryQueryState.listName() = listNameFlow().value + + fun DiscoveryQueryState.followsPerRelayFlow() = account.liveDiscoveryFollowListsPerRelay + + fun DiscoveryQueryState.followsPerRelay() = followsPerRelayFlow().value + + val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: DiscoveryQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.scope.launch(Dispatchers.Default) { + key.listNameFlow().collectLatest { + invalidateFilters() + } + }, + key.scope.launch(Dispatchers.Default) { + key.followsPerRelayFlow().sample(1000).collectLatest { + invalidateFilters() + } + }, + key.account.scope.launch(Dispatchers.Default) { + combine( + key.feedStates.discoverFollowSets.lastNoteCreatedAtWhenFullyLoaded, + key.feedStates.discoverLive.lastNoteCreatedAtWhenFullyLoaded, + ) { + Any() + }.sample(1000).collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryLongFormClassifiedsAndDVMSubAssembler1.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryLongFormClassifiedsAndDVMSubAssembler1.kt new file mode 100644 index 0000000000..00d94583e3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryLongFormClassifiedsAndDVMSubAssembler1.kt @@ -0,0 +1,108 @@ +/** + * 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.ui.screen.loggedIn.discover.datasource + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.makeLongFormFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.makeContentDVMsFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.makeClassifiedsFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch + +class DiscoveryLongFormClassifiedsAndDVMSubAssembler1( + client: NostrClient, + allKeys: () -> Set, +) : PerUserAndFollowListEoseManager(client, allKeys) { + override fun updateFilter( + key: DiscoveryQueryState, + since: SincePerRelayMap?, + ): List? { + val feedSettings = key.followsPerRelay() + + return makeLongFormFilter(feedSettings, since, key.feedStates.discoverReads.lastNoteCreatedAtIfFilled()) + + makeClassifiedsFilter(feedSettings, since, key.feedStates.discoverMarketplace.lastNoteCreatedAtIfFilled()) + + makeContentDVMsFilter(feedSettings, since, key.feedStates.discoverDVMs.lastNoteCreatedAtIfFilled()) + } + + override fun user(key: DiscoveryQueryState) = key.account.userProfile() + + override fun list(key: DiscoveryQueryState) = key.listName() + + fun DiscoveryQueryState.listNameFlow() = account.settings.defaultDiscoveryFollowList + + fun DiscoveryQueryState.listName() = listNameFlow().value + + fun DiscoveryQueryState.followsPerRelayFlow() = account.liveDiscoveryFollowListsPerRelay + + fun DiscoveryQueryState.followsPerRelay() = followsPerRelayFlow().value + + val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: DiscoveryQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.scope.launch(Dispatchers.Default) { + key.listNameFlow().collectLatest { + invalidateFilters() + } + }, + key.scope.launch(Dispatchers.Default) { + key.followsPerRelayFlow().sample(500).collectLatest { + invalidateFilters() + } + }, + key.account.scope.launch(Dispatchers.Default) { + combine( + key.feedStates.discoverReads.lastNoteCreatedAtWhenFullyLoaded, + key.feedStates.discoverDVMs.lastNoteCreatedAtWhenFullyLoaded, + key.feedStates.discoverMarketplace.lastNoteCreatedAtWhenFullyLoaded, + ) { + Any() + }.sample(1000).collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryPublicChatsAndCommunitiesSubAssembler3.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryPublicChatsAndCommunitiesSubAssembler3.kt new file mode 100644 index 0000000000..ef4fbb0119 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryPublicChatsAndCommunitiesSubAssembler3.kt @@ -0,0 +1,104 @@ +/** + * 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.ui.screen.loggedIn.discover.datasource + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.makePublicChatsFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.makeCommunitiesFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch + +class DiscoveryPublicChatsAndCommunitiesSubAssembler3( + client: NostrClient, + allKeys: () -> Set, +) : PerUserAndFollowListEoseManager(client, allKeys) { + override fun updateFilter( + key: DiscoveryQueryState, + since: SincePerRelayMap?, + ): List? { + val feedSettings = key.followsPerRelay() + return makePublicChatsFilter(feedSettings, since, key.feedStates.discoverPublicChats.lastNoteCreatedAtIfFilled()) + + makeCommunitiesFilter(feedSettings, since, key.feedStates.discoverCommunities.lastNoteCreatedAtIfFilled()) + } + + override fun user(key: DiscoveryQueryState) = key.account.userProfile() + + override fun list(key: DiscoveryQueryState) = key.listName() + + fun DiscoveryQueryState.listNameFlow() = account.settings.defaultDiscoveryFollowList + + fun DiscoveryQueryState.listName() = listNameFlow().value + + fun DiscoveryQueryState.followsPerRelayFlow() = account.liveDiscoveryFollowListsPerRelay + + fun DiscoveryQueryState.followsPerRelay() = followsPerRelayFlow().value + + val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: DiscoveryQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.scope.launch(Dispatchers.Default) { + key.listNameFlow().collectLatest { + invalidateFilters() + } + }, + key.scope.launch(Dispatchers.Default) { + key.followsPerRelayFlow().sample(500).collectLatest { + invalidateFilters() + } + }, + key.account.scope.launch(Dispatchers.Default) { + combine( + key.feedStates.discoverPublicChats.lastNoteCreatedAtWhenFullyLoaded, + key.feedStates.discoverCommunities.lastNoteCreatedAtWhenFullyLoaded, + ) { + Any() + }.sample(1000).collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/DiscoverLongFormFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/DiscoverLongFormFeedFilter.kt new file mode 100644 index 0000000000..0eea42a298 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/DiscoverLongFormFeedFilter.kt @@ -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.ui.screen.loggedIn.discover.nip23LongForm + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent + +open class DiscoverLongFormFeedFilter( + val account: Account, +) : AdditiveFeedFilter() { + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList() + + override fun limit() = 100 + + open fun followList(): String = account.settings.defaultDiscoveryFollowList.value + + override fun showHiddenKey(): Boolean = + followList() == PeopleListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) || + followList() == MuteListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) + + override fun feed(): List { + val params = buildFilterParams(account) + + val notes = + LocalCache.addressables.filterIntoSet { _, it -> + val noteEvent = it.event + noteEvent is LongTextNoteEvent && params.match(noteEvent) + } + + return sort(notes) + } + + override fun applyFilter(collection: Set): Set = innerApplyFilter(collection) + + fun buildFilterParams(account: Account): FilterByListParams = + FilterByListParams.create( + account.liveDiscoveryFollowLists.value, + account.hiddenUsers.flow.value, + ) + + protected open fun innerApplyFilter(collection: Collection): Set { + val params = buildFilterParams(account) + + return collection.filterTo(HashSet()) { + val noteEvent = it.event + noteEvent is LongTextNoteEvent && params.match(noteEvent) + } + } + + override fun sort(collection: Set): List = collection.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPublicFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormCard.kt similarity index 56% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPublicFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormCard.kt index 6697e73e1c..317275f2e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPublicFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormCard.kt @@ -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,30 +18,30 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.types.LongFormHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -class BookmarkPublicFeedFilter( - val account: Account, -) : FeedFilter() { - override fun feedKey(): String = account.userProfile().latestBookmarkList?.id ?: "" +@Composable +fun RenderLongFormThumb( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent by observeNoteEvent(baseNote, accountViewModel) - override fun feed(): List { - val bookmarks = account.userProfile().latestBookmarkList - - return bookmarks - ?.tags - ?.mapNotNull { - if (it.size > 1 && it[0] == "e") { - LocalCache.checkGetOrCreateNote(it[1]) - } else if (it.size > 1 && it[0] == "a") { - LocalCache.checkGetOrCreateAddressableNote(it[1]) - } else { - null - } - }?.reversed() ?: emptyList() + noteEvent?.let { + LongFormHeader( + it, + baseNote, + accountViewModel, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/SubAssemblyHelper.kt new file mode 100644 index 0000000000..a6c14d36d5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/SubAssemblyHelper.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByAllCommunities +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByCommunity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByGeohash +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormGlobal +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun makeLongFormFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List = + when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterLongFormByAllCommunities(feedSettings, since, defaultSince) + is AllFollowsTopNavPerRelayFilterSet -> filterLongFormByFollows(feedSettings, since, defaultSince) + is AuthorsTopNavPerRelayFilterSet -> filterLongFormByAuthors(feedSettings, since, defaultSince) + is GlobalTopNavPerRelayFilterSet -> filterLongFormGlobal(feedSettings, since, defaultSince) + is HashtagTopNavPerRelayFilterSet -> filterLongFormByHashtag(feedSettings, since, defaultSince) + is LocationTopNavPerRelayFilterSet -> filterLongFormByGeohash(feedSettings, since, defaultSince) + is MutedAuthorsTopNavPerRelayFilterSet -> filterLongFormByAuthors(feedSettings, since, defaultSince) + is SingleCommunityTopNavPerRelayFilterSet -> filterLongFormByCommunity(feedSettings, since, defaultSince) + else -> emptyList() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAllCommunities.kt new file mode 100644 index 0000000000..44413e7ba6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAllCommunities.kt @@ -0,0 +1,83 @@ +/** + * 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.ui.screen.loggedIn.discover.nip23LongForm.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent + +fun filterLongFormAllCommunities( + relay: NormalizedRelayUrl, + communities: Set, + since: Long? = null, +): List { + val communityList = communities.sorted() + + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to communityList, + "k" to listOf(LongTextNoteEvent.KIND.toString()), + ), + limit = 30, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("k" to listOf("5300"), "a" to communityList), + kinds = listOf(LongTextNoteEvent.KIND), + limit = 30, + since = since, + ), + ), + ) +} + +fun filterLongFormByAllCommunities( + communitySet: AllCommunitiesTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterLongFormAllCommunities( + relay = it.key, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAuthors.kt new file mode 100644 index 0000000000..eb2d4311db --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAuthors.kt @@ -0,0 +1,92 @@ +/** + * 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.ui.screen.loggedIn.discover.nip23LongForm.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent + +fun filterLongFormAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = listOf(LongTextNoteEvent.KIND), + limit = 200, + since = since, + ), + ), + ) +} + +fun filterLongFormByAuthors( + authorSet: AuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterLongFormAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterLongFormByAuthors( + authorSet: MutedAuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterLongFormAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByCommunity.kt new file mode 100644 index 0000000000..af7f7b963d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByCommunity.kt @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent + +fun filterLongFormByCommunity( + relay: NormalizedRelayUrl, + community: String, + authors: Set?, + since: Long? = null, +): List { + val authors = authors?.sorted() + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to listOf(community), + "k" to listOf(LongTextNoteEvent.KIND.toString()), + ), + limit = 30, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + tags = mapOf("k" to listOf("5300"), "a" to listOf(community)), + kinds = listOf(LongTextNoteEvent.KIND), + limit = 30, + since = since, + ), + ), + ) +} + +fun filterLongFormByCommunity( + communitySet: SingleCommunityTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterLongFormByCommunity( + relay = it.key, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByFollows.kt new file mode 100644 index 0000000000..1fb64fd407 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByFollows.kt @@ -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.ui.screen.loggedIn.discover.nip23LongForm.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun filterLongFormByFollows( + followsSet: AllFollowsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (followsSet.set.isEmpty()) return emptyList() + + return followsSet.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + val relay = it.key + + listOfNotNull( + it.value.authors?.let { + filterLongFormAuthors(relay, it, since) + }, + it.value.geotags?.let { + filterLongFormByGeohash(relay, it, since) + }, + it.value.hashtags?.let { + filterLongFormByHashtag(relay, it, since) + }, + it.value.communities?.let { + filterLongFormAllCommunities(relay, it, since) + }, + ).flatten() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByGeohash.kt new file mode 100644 index 0000000000..2346b02a7c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByGeohash.kt @@ -0,0 +1,73 @@ +/** + * 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.ui.screen.loggedIn.discover.nip23LongForm.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByGeohash +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent + +fun filterLongFormByGeohash( + relay: NormalizedRelayUrl, + geotags: Set, + since: Long? = null, +): List { + if (geotags.isEmpty()) return emptyList() + + val geoHashes = geotags.sorted() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(LongTextNoteEvent.KIND), + tags = mapOf("g" to geoHashes), + limit = 100, + since = since, + ), + ), + ) +} + +fun filterLongFormByGeohash( + geoSet: LocationTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (geoSet.set.isEmpty()) return emptyList() + + return geoSet.set + .mapNotNull { + if (it.value.geotags.isEmpty()) { + null + } else { + filterLiveActivitiesByGeohash( + relay = it.key, + geotags = it.value.geotags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByHashtag.kt new file mode 100644 index 0000000000..26dae5a7cd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByHashtag.kt @@ -0,0 +1,73 @@ +/** + * 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.ui.screen.loggedIn.discover.nip23LongForm.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent + +fun filterLongFormByHashtag( + relay: NormalizedRelayUrl, + hashtags: Set, + since: Long?, +): List { + if (hashtags.isEmpty()) return emptyList() + + val hashtags = hashtags.flatMap(::hashtagAlts).distinct().sorted() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(LongTextNoteEvent.KIND), + tags = mapOf("t" to hashtags), + limit = 200, + since = since, + ), + ), + ) +} + +fun filterLongFormByHashtag( + hashSet: HashtagTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (hashSet.set.isEmpty()) return emptyList() + + return hashSet.set + .mapNotNull { relayHashSet -> + if (relayHashSet.value.hashtags.isEmpty()) { + null + } else { + filterLongFormByHashtag( + relay = relayHashSet.key, + hashtags = relayHashSet.value.hashtags, + since = since?.get(relayHashSet.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormGlobal.kt new file mode 100644 index 0000000000..4c879bbb32 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormGlobal.kt @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +fun filterLongFormGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (relays.set.isEmpty()) return emptyList() + + return relays.set.map { + val since = since?.get(it.key)?.time ?: defaultSince ?: TimeUtils.oneWeekAgo() + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = listOf(LongTextNoteEvent.KIND), + limit = 100, + since = since, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverChatFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/DiscoverChatFeedFilter.kt similarity index 74% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverChatFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/DiscoverChatFeedFilter.kt index f9d25420b1..c7c4e9ca5e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverChatFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/DiscoverChatFeedFilter.kt @@ -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,42 +18,41 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent open class DiscoverChatFeedFilter( val account: Account, ) : AdditiveFeedFilter() { override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultDiscoveryFollowList.value + override fun limit() = 100 + override fun showHiddenKey(): Boolean = account.settings.defaultDiscoveryFollowList.value == - PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + PeopleListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) || account.settings.defaultDiscoveryFollowList.value == - MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + MuteListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) override fun feed(): List { val params = buildFilterParams(account) val allChannelNotes = - LocalCache.channels.mapNotNullIntoSet { _, channel -> - if (channel is PublicChatChannel) { - val note = LocalCache.getNoteIfExists(channel.idHex) - val noteEvent = note?.event + LocalCache.publicChatChannels.mapNotNullIntoSet { _, channel -> + val note = LocalCache.getNoteIfExists(channel.idHex) + val noteEvent = note?.event - if (noteEvent == null || params.match(noteEvent)) { - note - } else { - null - } + if (noteEvent == null || params.match(noteEvent)) { + note } else { null } @@ -66,10 +65,8 @@ open class DiscoverChatFeedFilter( fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( - userHex = account.userProfile().pubkeyHex, - selectedListName = account.settings.defaultDiscoveryFollowList.value, followLists = account.liveDiscoveryFollowLists.value, - hiddenUsers = account.flowHiddenUsers.value, + hiddenUsers = account.hiddenUsers.flow.value, ) protected open fun innerApplyFilter(collection: Collection): Set { @@ -79,7 +76,7 @@ open class DiscoverChatFeedFilter( // note event here will never be null val noteEvent = note.event if (noteEvent is ChannelCreateEvent && params.match(noteEvent)) { - if ((LocalCache.getChannelIfExists(noteEvent.id)?.notes?.size() ?: 0) > 0) { + if ((LocalCache.getPublicChatChannelIfExists(noteEvent.id)?.notes?.size() ?: 0) > 0) { note } else { null @@ -91,7 +88,7 @@ open class DiscoverChatFeedFilter( if (channel != null && (channelEvent == null || (channelEvent is ChannelCreateEvent && params.match(channelEvent))) ) { - if ((LocalCache.getChannelIfExists(channel.idHex)?.notes?.size() ?: 0) > 0) { + if ((LocalCache.getPublicChatChannelIfExists(channel.idHex)?.notes?.size() ?: 0) > 0) { channel } else { null @@ -108,7 +105,7 @@ open class DiscoverChatFeedFilter( override fun sort(collection: Set): List { val lastNote = collection.associateWith { note -> - LocalCache.getChannelIfExists(note.idHex)?.lastNoteCreatedAt ?: 0 + LocalCache.getPublicChatChannelIfExists(note.idHex)?.lastNote?.createdAt() ?: 0 } return collection diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/RenderPublicChatChannelThumb.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/RenderPublicChatChannelThumb.kt new file mode 100644 index 0000000000..0d4c93a036 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/RenderPublicChatChannelThumb.kt @@ -0,0 +1,214 @@ +/** + * 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.ui.screen.loggedIn.discover.nip28Chats + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import coil3.compose.AsyncImagePainter +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.ParticipantListBuilder +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByProxyTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByProxyTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel +import com.vitorpamplona.amethyst.ui.layouts.LeftPictureLayout +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner +import com.vitorpamplona.amethyst.ui.note.Gallery +import com.vitorpamplona.amethyst.ui.note.LikeReaction +import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel +import com.vitorpamplona.amethyst.ui.note.ZapReaction +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding +import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.RowColSpacing +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun RenderPublicChatChannelThumb( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = baseNote.event as? ChannelCreateEvent ?: return + + LoadPublicChatChannel(baseNote.idHex, accountViewModel) { + RenderPublicChatChannelThumb(baseNote = baseNote, channel = it, accountViewModel, nav) + } +} + +@Composable +fun RenderPublicChatChannelThumb( + baseNote: Note, + channel: PublicChatChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val channelUpdates by observeChannel(channel, accountViewModel) + val publicChat = channelUpdates?.channel as PublicChatChannel + + val name = remember(channelUpdates) { publicChat.toBestDisplayName() } + val description = remember(channelUpdates) { publicChat.summary()?.ifBlank { null } } + var cover by remember(channelUpdates) { mutableStateOf(publicChat.profilePicture()?.ifBlank { null }) } + + var participantUsers by + remember(baseNote) { + mutableStateOf>( + persistentListOf(), + ) + } + + LaunchedEffect(key1 = channelUpdates) { + launch(Dispatchers.IO) { + val topFilter = accountViewModel.account.liveDiscoveryFollowLists.value + val topFilterAuthors = + when (topFilter) { + is AuthorsByOutboxTopNavFilter -> topFilter.authors + is MutedAuthorsByOutboxTopNavFilter -> topFilter.authors + is AllFollowsByOutboxTopNavFilter -> topFilter.authors + is SingleCommunityTopNavFilter -> topFilter.authors + is AuthorsByProxyTopNavFilter -> topFilter.authors + is MutedAuthorsByProxyTopNavFilter -> topFilter.authors + is AllFollowsByProxyTopNavFilter -> topFilter.authors + else -> null + } + + val followingKeySet = topFilterAuthors + val allParticipants = + ParticipantListBuilder() + .followsThatParticipateOn(baseNote, followingKeySet) + .toImmutableList() + + val newParticipantUsers = + if (followingKeySet == null) { + val allFollows = accountViewModel.account.kind3FollowList.flow.value.authors + val followingParticipants = + ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).toList() + + (followingParticipants + (allParticipants - followingParticipants)).toImmutableList() + } else { + allParticipants.toImmutableList() + } + + if (!equalImmutableLists(newParticipantUsers, participantUsers)) { + participantUsers = newParticipantUsers + } + } + } + + LeftPictureLayout( + onImage = { + cover?.let { + AsyncImage( + model = it, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = + Modifier + .fillMaxSize() + .clip(QuoteBorder), + onState = { + if (it is AsyncImagePainter.State.Error) { + cover = null + } + }, + ) + } ?: run { DisplayAuthorBanner(baseNote, accountViewModel) } + }, + onTitleRow = { + Text( + text = name, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = StdHorzSpacer) + Row( + verticalAlignment = CenterVertically, + horizontalArrangement = RowColSpacing, + ) { + LikeReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav, + ) + } + Spacer(modifier = StdHorzSpacer) + ZapReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + onDescription = { + Text( + text = description ?: stringRes(R.string.chat_about_topic, name), + color = MaterialTheme.colorScheme.grayText, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + fontSize = 14.sp, + lineHeight = 18.sp, + modifier = HalfTopPadding, + ) + }, + onBottomRow = { + if (participantUsers.isNotEmpty()) { + Gallery(participantUsers, HalfTopPadding, accountViewModel, nav) + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/SubAssemblyHelper.kt new file mode 100644 index 0000000000..3cc60bb857 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/SubAssemblyHelper.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByAllCommunities +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByCommunity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByGeohash +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsGlobal +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun makePublicChatsFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List = + when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterPublicChatsByAllCommunities(feedSettings, since, defaultSince) + is AllFollowsTopNavPerRelayFilterSet -> filterPublicChatsByFollows(feedSettings, since, defaultSince) + is AuthorsTopNavPerRelayFilterSet -> filterPublicChatsByAuthors(feedSettings, since, defaultSince) + is GlobalTopNavPerRelayFilterSet -> filterPublicChatsGlobal(feedSettings, since, defaultSince) + is HashtagTopNavPerRelayFilterSet -> filterPublicChatsByHashtag(feedSettings, since, defaultSince) + is LocationTopNavPerRelayFilterSet -> filterPublicChatsByGeohash(feedSettings, since, defaultSince) + is MutedAuthorsTopNavPerRelayFilterSet -> filterPublicChatsByAuthors(feedSettings, since, defaultSince) + is SingleCommunityTopNavPerRelayFilterSet -> filterPublicChatsByCommunity(feedSettings, since, defaultSince) + else -> emptyList() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAllCommunities.kt new file mode 100644 index 0000000000..437237ba11 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAllCommunities.kt @@ -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.ui.screen.loggedIn.discover.nip28Chats.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +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.nip72ModCommunities.approval.CommunityPostApprovalEvent + +fun filterPublicChatsAllCommunities( + relay: NormalizedRelayUrl, + communities: Set, + since: Long? = null, +): List { + val communityList = communities.sorted() + + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to communityList, + "k" to listOf(ChannelCreateEvent.KIND.toString(), ChannelMetadataEvent.KIND.toString(), ChannelMessageEvent.KIND.toString()), + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("k" to listOf("5300"), "a" to communityList), + kinds = + listOf( + ChannelCreateEvent.KIND, + ChannelMetadataEvent.KIND, + ChannelMessageEvent.KIND, + ), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterPublicChatsByAllCommunities( + communitySet: AllCommunitiesTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + return communitySet.set + .mapNotNull { + filterPublicChatsAllCommunities( + relay = it.key, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAuthors.kt new file mode 100644 index 0000000000..ce6db72046 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAuthors.kt @@ -0,0 +1,99 @@ +/** + * 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.ui.screen.loggedIn.discover.nip28Chats.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent + +fun filterPublicChatsAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = + listOf( + ChannelCreateEvent.KIND, + ChannelMetadataEvent.KIND, + ChannelMessageEvent.KIND, + ), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterPublicChatsByAuthors( + authorSet: AuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterPublicChatsAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterPublicChatsByAuthors( + authorSet: MutedAuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterPublicChatsAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByCommunity.kt new file mode 100644 index 0000000000..020916f4cf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByCommunity.kt @@ -0,0 +1,93 @@ +/** + * 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.ui.screen.loggedIn.discover.nip28Chats.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +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.nip72ModCommunities.approval.CommunityPostApprovalEvent + +fun filterPublicChatsByCommunity( + relay: NormalizedRelayUrl, + community: String, + authors: Set?, + since: Long? = null, +): List { + val authors = authors?.sorted() + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to listOf(community), + "k" to listOf(ChannelCreateEvent.KIND.toString(), ChannelMetadataEvent.KIND.toString(), ChannelMessageEvent.KIND.toString()), + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + tags = mapOf("k" to listOf("5300"), "a" to listOf(community)), + kinds = + listOf( + ChannelCreateEvent.KIND, + ChannelMetadataEvent.KIND, + ChannelMessageEvent.KIND, + ), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterPublicChatsByCommunity( + communitySet: SingleCommunityTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterPublicChatsByCommunity( + relay = it.key, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByFollows.kt new file mode 100644 index 0000000000..2ee9441872 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByFollows.kt @@ -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.ui.screen.loggedIn.discover.nip28Chats.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun filterPublicChatsByFollows( + followsSet: AllFollowsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (followsSet.set.isEmpty()) return emptyList() + + return followsSet.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + val relay = it.key + + listOfNotNull( + it.value.authors?.let { + filterPublicChatsAuthors(relay, it, since) + }, + it.value.geotags?.let { + filterPublicChatsByGeohash(relay, it, since) + }, + it.value.hashtags?.let { + filterPublicChatsByHashtag(relay, it, since) + }, + it.value.communities?.let { + filterPublicChatsAllCommunities(relay, it, since) + }, + ).flatten() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByGeohash.kt new file mode 100644 index 0000000000..778834c649 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByGeohash.kt @@ -0,0 +1,79 @@ +/** + * 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.ui.screen.loggedIn.discover.nip28Chats.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent + +fun filterPublicChatsByGeohash( + relay: NormalizedRelayUrl, + geotags: Set, + since: Long? = null, +): List { + if (geotags.isEmpty()) return emptyList() + + val geoHashes = geotags.sorted() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = + listOf( + ChannelCreateEvent.KIND, + ChannelMetadataEvent.KIND, + ChannelMessageEvent.KIND, + ), + tags = mapOf("g" to geoHashes), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterPublicChatsByGeohash( + geoSet: LocationTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (geoSet.set.isEmpty()) return emptyList() + + return geoSet.set + .mapNotNull { + if (it.value.geotags.isEmpty()) { + null + } else { + filterPublicChatsByGeohash( + relay = it.key, + geotags = it.value.geotags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByHashtag.kt new file mode 100644 index 0000000000..a2d147ad12 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByHashtag.kt @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent + +fun filterPublicChatsByHashtag( + relay: NormalizedRelayUrl, + hashtags: Set, + since: Long?, +): List { + if (hashtags.isEmpty()) return emptyList() + + val hashtags = hashtags.flatMap(::hashtagAlts).distinct().sorted() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = + listOf( + ChannelCreateEvent.KIND, + ChannelMetadataEvent.KIND, + ChannelMessageEvent.KIND, + ), + tags = mapOf("t" to hashtags), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterPublicChatsByHashtag( + hashSet: HashtagTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (hashSet.set.isEmpty()) return emptyList() + + return hashSet.set + .mapNotNull { relayHashSet -> + if (relayHashSet.value.hashtags.isEmpty()) { + null + } else { + filterPublicChatsByHashtag( + relay = relayHashSet.key, + hashtags = relayHashSet.value.hashtags, + since = since?.get(relayHashSet.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsGlobal.kt new file mode 100644 index 0000000000..defd9b5022 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsGlobal.kt @@ -0,0 +1,69 @@ +/** + * 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.ui.screen.loggedIn.discover.nip28Chats.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +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.utils.TimeUtils + +fun filterPublicChatsGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (relays.set.isEmpty()) return emptyList() + + return relays.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + listOf( + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = + listOf( + ChannelCreateEvent.KIND, + ChannelMetadataEvent.KIND, + ), + limit = 30, + since = since, + ), + ), + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = + listOf( + ChannelMessageEvent.KIND, + ), + limit = 50, + since = since ?: TimeUtils.oneWeekAgo(), + ), + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/DiscoverFollowSetsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/DiscoverFollowSetsFeedFilter.kt new file mode 100644 index 0000000000..07d278ff2f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/DiscoverFollowSetsFeedFilter.kt @@ -0,0 +1,74 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent + +open class DiscoverFollowSetsFeedFilter( + val account: Account, +) : AdditiveFeedFilter() { + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList() + + open fun followList(): String = account.settings.defaultDiscoveryFollowList.value + + override fun showHiddenKey(): Boolean = + followList() == PeopleListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) || + followList() == MuteListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) + + override fun feed(): List { + val params = buildFilterParams(account) + + val notes = + LocalCache.addressables.filterIntoSet { _, it -> + val noteEvent = it.event + noteEvent is FollowListEvent && params.match(noteEvent) + } + + return sort(notes) + } + + override fun applyFilter(collection: Set): Set = innerApplyFilter(collection) + + fun buildFilterParams(account: Account): FilterByListParams = + FilterByListParams.create( + account.liveDiscoveryFollowLists.value, + account.hiddenUsers.flow.value, + ) + + protected open fun innerApplyFilter(collection: Collection): Set { + val params = buildFilterParams(account) + + return collection.filterTo(HashSet()) { + val noteEvent = it.event + noteEvent is FollowListEvent && params.match(noteEvent) + } + } + + override fun sort(collection: Set): List = collection.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/FollowSetCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/FollowSetCard.kt new file mode 100644 index 0000000000..dda0079c93 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/FollowSetCard.kt @@ -0,0 +1,209 @@ +/** + * 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.ui.screen.loggedIn.discover.nip51FollowSets + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.Gallery +import com.vitorpamplona.amethyst.ui.note.LikeReaction +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.ZapReaction +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.FollowSetImageModifier +import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size25dp +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +@Immutable +data class FollowSetCard( + val name: String, + val media: String?, + val description: String?, + val users: ImmutableList, +) + +@Composable +fun RenderFollowSetThumb( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val card by observeNoteAndMap(baseNote, accountViewModel) { + val noteEvent = it.event as? FollowListEvent + + FollowSetCard( + name = noteEvent?.title()?.ifBlank { null } ?: noteEvent?.dTag() ?: "", + media = noteEvent?.image()?.ifBlank { null }, + description = noteEvent?.description(), + users = + accountViewModel + .loadUsersSync( + noteEvent?.followIds() ?: emptyList(), + ).toImmutableList(), + ) + } + + RenderFollowSetThumb( + card, + baseNote, + accountViewModel, + nav, + ) +} + +@Preview +@Composable +fun RenderFollowSetThumbPreview() { + val accountViewModel = mockAccountViewModel() + val nav = EmptyNav + + ThemeComparisonColumn( + toPreview = { + RenderFollowSetThumb( + card = + FollowSetCard( + "Orange Pill Perú", + "https://i.postimg.cc/GtDgGY5v/5062563795762785335.jpg", + "Desc", + persistentListOf( + accountViewModel.userProfile(), + accountViewModel.userProfile(), + accountViewModel.userProfile(), + accountViewModel.userProfile(), + accountViewModel.userProfile(), + ), + ), + baseNote = Note(""), + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) +} + +@Composable +fun RenderFollowSetThumb( + card: FollowSetCard, + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Box( + contentAlignment = Alignment.BottomStart, + ) { + card.media?.let { + MyAsyncImage( + imageUrl = it, + contentDescription = + stringRes( + R.string.preview_card_image_for, + it, + ), + contentScale = ContentScale.Crop, + mainImageModifier = Modifier, + loadedImageModifier = FollowSetImageModifier, + accountViewModel = accountViewModel, + onLoadingBackground = { DefaultImageHeaderBackground(baseNote, accountViewModel) }, + onError = { DefaultImageHeader(baseNote, accountViewModel) }, + ) + } ?: run { DefaultImageHeader(baseNote, accountViewModel, FollowSetImageModifier) } + + Gallery(card.users, Modifier.padding(Size10dp), accountViewModel, nav) + } + + Spacer(modifier = DoubleVertSpacer) + + Row( + verticalAlignment = CenterVertically, + horizontalArrangement = RowColSpacing5dp, + ) { + Text( + text = card.name, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = StdHorzSpacer) + LikeReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav, + ) + Spacer(modifier = StdHorzSpacer) + ZapReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav = nav, + ) + } + baseNote.author?.let { author -> + Spacer(modifier = DoubleVertSpacer) + Row( + verticalAlignment = CenterVertically, + horizontalArrangement = RowColSpacing5dp, + ) { + UserPicture(author, Size25dp, accountViewModel = accountViewModel, nav = nav) + UsernameDisplay(author, fontWeight = FontWeight.Normal, accountViewModel = accountViewModel) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/SubAssemblyHelper.kt new file mode 100644 index 0000000000..44cd0f58e2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/SubAssemblyHelper.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByAllCommunities +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByCommunity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByGeohash +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsGlobal +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun makeFollowSetsFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List = + when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterFollowSetsByAllCommunities(feedSettings, since, defaultSince) + is AllFollowsTopNavPerRelayFilterSet -> filterFollowSetsByFollows(feedSettings, since, defaultSince) + is AuthorsTopNavPerRelayFilterSet -> filterFollowSetsByAuthors(feedSettings, since, defaultSince) + is GlobalTopNavPerRelayFilterSet -> filterFollowSetsGlobal(feedSettings, since, defaultSince) + is HashtagTopNavPerRelayFilterSet -> filterFollowSetsByHashtag(feedSettings, since, defaultSince) + is LocationTopNavPerRelayFilterSet -> filterFollowSetsByGeohash(feedSettings, since, defaultSince) + is MutedAuthorsTopNavPerRelayFilterSet -> filterFollowSetsByAuthors(feedSettings, since, defaultSince) + is SingleCommunityTopNavPerRelayFilterSet -> filterFollowSetsByCommunity(feedSettings, since, defaultSince) + else -> emptyList() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAllCommunities.kt new file mode 100644 index 0000000000..865adb4d33 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAllCommunities.kt @@ -0,0 +1,84 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsAllCommunities +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent + +fun filterFollowSetsAllCommunities( + relay: NormalizedRelayUrl, + communities: Set, + since: Long? = null, +): List { + val communityList = communities.sorted() + + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to communityList, + "k" to listOf(FollowListEvent.KIND.toString()), + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("k" to listOf("5300"), "a" to communityList), + kinds = listOf(FollowListEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterFollowSetsByAllCommunities( + communitySet: AllCommunitiesTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterContentDVMsAllCommunities( + relay = it.key, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAuthors.kt new file mode 100644 index 0000000000..bc1139bc23 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAuthors.kt @@ -0,0 +1,92 @@ +/** + * 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.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent + +fun filterFollowSetsAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = listOf(FollowListEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterFollowSetsByAuthors( + authorSet: AuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterFollowSetsAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterFollowSetsByAuthors( + authorSet: MutedAuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterFollowSetsAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByCommunity.kt new file mode 100644 index 0000000000..fe8e661bf4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByCommunity.kt @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent + +fun filterFollowSetsByCommunity( + relay: NormalizedRelayUrl, + community: String, + authors: Set?, + since: Long? = null, +): List { + val authors = authors?.sorted() + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to listOf(community), + "k" to listOf(FollowListEvent.KIND.toString()), + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + tags = mapOf("k" to listOf("5300"), "a" to listOf(community)), + kinds = listOf(FollowListEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterFollowSetsByCommunity( + communitySet: SingleCommunityTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterFollowSetsByCommunity( + relay = it.key, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByFollows.kt new file mode 100644 index 0000000000..063cfe7867 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByFollows.kt @@ -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.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun filterFollowSetsByFollows( + followsSet: AllFollowsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (followsSet.set.isEmpty()) return emptyList() + + return followsSet.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + val relay = it.key + + listOfNotNull( + it.value.authors?.let { + filterFollowSetsAuthors(relay, it, since) + }, + it.value.geotags?.let { + filterFollowSetsByGeohash(relay, it, since) + }, + it.value.hashtags?.let { + filterFollowSetsByHashtag(relay, it, since) + }, + it.value.communities?.let { + filterFollowSetsAllCommunities(relay, it, since) + }, + ).flatten() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByGeohash.kt new file mode 100644 index 0000000000..b6ff52cc42 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByGeohash.kt @@ -0,0 +1,72 @@ +/** + * 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.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent + +fun filterFollowSetsByGeohash( + relay: NormalizedRelayUrl, + geotags: Set, + since: Long? = null, +): List { + if (geotags.isEmpty()) return emptyList() + + val geoHashes = geotags.sorted() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(FollowListEvent.KIND), + tags = mapOf("g" to geoHashes), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterFollowSetsByGeohash( + geoSet: LocationTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (geoSet.set.isEmpty()) return emptyList() + + return geoSet.set + .mapNotNull { + if (it.value.geotags.isEmpty()) { + null + } else { + filterFollowSetsByGeohash( + relay = it.key, + geotags = it.value.geotags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByHashtag.kt new file mode 100644 index 0000000000..1301091733 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByHashtag.kt @@ -0,0 +1,73 @@ +/** + * 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.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent + +fun filterFollowSetsByHashtag( + relay: NormalizedRelayUrl, + hashtags: Set, + since: Long?, +): List { + if (hashtags.isEmpty()) return emptyList() + + val hashtags = hashtags.flatMap(::hashtagAlts).distinct().sorted() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(FollowListEvent.KIND), + tags = mapOf("t" to hashtags), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterFollowSetsByHashtag( + hashSet: HashtagTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (hashSet.set.isEmpty()) return emptyList() + + return hashSet.set + .mapNotNull { relayHashSet -> + if (relayHashSet.value.hashtags.isEmpty()) { + null + } else { + filterFollowSetsByHashtag( + relay = relayHashSet.key, + hashtags = relayHashSet.value.hashtags, + since = since?.get(relayHashSet.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsGlobal.kt new file mode 100644 index 0000000000..661e9ca700 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsGlobal.kt @@ -0,0 +1,48 @@ +/** + * 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.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent + +fun filterFollowSetsGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (relays.set.isEmpty()) return emptyList() + + return relays.set.map { + val since = since?.get(it.key)?.time ?: defaultSince + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = listOf(FollowListEvent.KIND), + limit = 100, + since = since, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt similarity index 56% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt index 85c56cba2a..bf2f1db737 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt @@ -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,14 +18,23 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.ParticipantListBuilder -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByProxyTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByProxyTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag @@ -36,13 +45,15 @@ open class DiscoverLiveFeedFilter( open fun followList(): String = account.settings.defaultDiscoveryFollowList.value + override fun limit() = 50 + override fun showHiddenKey(): Boolean = - followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - followList() == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + followList() == PeopleListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) || + followList() == MuteListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) override fun feed(): List { - val allChannelNotes = LocalCache.channels.mapNotNull { _, channel -> LocalCache.getNoteIfExists(channel.idHex) } - val allMessageNotes = LocalCache.channels.map { _, channel -> channel.notes.filter { key, it -> it.event is LiveActivitiesEvent } }.flatten() + val allChannelNotes = LocalCache.liveChatChannels.mapNotNull { _, channel -> LocalCache.getAddressableNoteIfExists(channel.address) } + val allMessageNotes = LocalCache.liveChatChannels.map { _, channel -> channel.notes.filter { key, it -> it.event is LiveActivitiesEvent } }.flatten() val notes = innerApplyFilter(allChannelNotes + allMessageNotes) @@ -54,10 +65,8 @@ open class DiscoverLiveFeedFilter( protected open fun innerApplyFilter(collection: Collection): Set { val filterParams = FilterByListParams.create( - userHex = account.userProfile().pubkeyHex, - selectedListName = account.settings.defaultDiscoveryFollowList.value, followLists = account.liveDiscoveryFollowLists.value, - hiddenUsers = account.flowHiddenUsers.value, + hiddenUsers = account.hiddenUsers.flow.value, ) return collection.filterTo(HashSet()) { @@ -67,8 +76,21 @@ open class DiscoverLiveFeedFilter( } override fun sort(collection: Set): List { + val topFilter = account.liveDiscoveryFollowLists.value + val discoveryTopFilterAuthors = + when (topFilter) { + is AuthorsByOutboxTopNavFilter -> topFilter.authors + is MutedAuthorsByOutboxTopNavFilter -> topFilter.authors + is AllFollowsByOutboxTopNavFilter -> topFilter.authors + is SingleCommunityTopNavFilter -> topFilter.authors + is AuthorsByProxyTopNavFilter -> topFilter.authors + is MutedAuthorsByProxyTopNavFilter -> topFilter.authors + is AllFollowsByProxyTopNavFilter -> topFilter.authors + else -> null + } + val followingKeySet = - account.liveDiscoveryFollowLists.value?.authors ?: account.liveKind3Follows.value.authors + discoveryTopFilterAuthors ?: account.kind3FollowList.flow.value.authors val counter = ParticipantListBuilder() val participantCounts = @@ -89,11 +111,11 @@ open class DiscoverLiveFeedFilter( ).reversed() } - fun convertStatusToOrder(status: String?): Int = + fun convertStatusToOrder(status: StatusTag.STATUS?): Int = when (status) { - StatusTag.STATUS.LIVE.code -> 2 - StatusTag.STATUS.PLANNED.code -> 1 - StatusTag.STATUS.ENDED.code -> 0 + StatusTag.STATUS.LIVE -> 2 + StatusTag.STATUS.PLANNED -> 1 + StatusTag.STATUS.ENDED -> 0 else -> 0 } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/LiveActivityCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/LiveActivityCard.kt new file mode 100644 index 0000000000..b04f7b8f27 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/LiveActivityCard.kt @@ -0,0 +1,280 @@ +/** + * 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.ui.screen.loggedIn.discover.nip53LiveActivities + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment.Companion.BottomStart +import androidx.compose.ui.Alignment.Companion.TopEnd +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.ParticipantListBuilder +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByProxyTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByProxyTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap +import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner +import com.vitorpamplona.amethyst.ui.note.Gallery +import com.vitorpamplona.amethyst.ui.note.LoadLiveActivityChannel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.EndedFlag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivitiesChannelHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveFlag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.OfflineFlag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.ScheduledFlag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CheckIfVideoIsOnline +import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Immutable +data class LiveActivityCard( + val id: Address?, + val name: String, + val cover: String?, + val media: String?, + val subject: String?, + val content: String?, + val participants: ImmutableList, + val status: StatusTag.STATUS?, + val starts: Long?, +) + +@Composable +fun RenderLiveActivityThumb( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val card by observeNoteAndMap(baseNote, accountViewModel) { + val noteEvent = it.event as? LiveActivitiesEvent + + LiveActivityCard( + id = noteEvent?.address(), + name = noteEvent?.dTag() ?: "", + cover = noteEvent?.image()?.ifBlank { null }, + media = noteEvent?.streaming(), + subject = noteEvent?.title()?.ifBlank { null }, + content = noteEvent?.summary(), + participants = noteEvent?.participants()?.toImmutableList() ?: persistentListOf(), + status = noteEvent?.status(), + starts = noteEvent?.starts(), + ) + } + + RenderLiveActivityThumb( + card, + baseNote, + accountViewModel, + nav, + ) +} + +@Composable +fun RenderLiveActivityThumb( + card: LiveActivityCard, + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Box( + contentAlignment = TopEnd, + modifier = + Modifier + .aspectRatio(ratio = 16f / 9f) + .fillMaxWidth(), + ) { + card.cover?.let { + AsyncImage( + model = it, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = + Modifier + .fillMaxSize() + .clip(QuoteBorder), + ) + } ?: run { DisplayAuthorBanner(baseNote, accountViewModel) } + + Box(Modifier.padding(10.dp)) { + CrossfadeIfEnabled(targetState = card.status, label = "RenderLiveActivityThumb", accountViewModel = accountViewModel) { + when (it) { + StatusTag.STATUS.LIVE -> { + val url = card.media + if (url.isNullOrBlank()) { + LiveFlag() + } else { + CheckIfVideoIsOnline(url, accountViewModel) { isOnline -> + if (isOnline) { + LiveFlag() + } else { + OfflineFlag() + } + } + } + } + StatusTag.STATUS.ENDED -> { + EndedFlag() + } + StatusTag.STATUS.PLANNED -> { + ScheduledFlag(card.starts) + } + else -> { + EndedFlag() + } + } + } + } + + LoadParticipants(card.participants, baseNote, accountViewModel) { participantUsers -> + Box( + Modifier + .padding(10.dp) + .align(BottomStart), + ) { + if (participantUsers.isNotEmpty()) { + Gallery(participantUsers, Modifier, accountViewModel, nav) + } + } + } + } + + Spacer(modifier = DoubleVertSpacer) + + baseNote.address()?.let { + LoadLiveActivityChannel(it, accountViewModel) { + LiveActivitiesChannelHeader( + baseChannel = it, + showVideo = false, + showFlag = false, + sendToChannel = true, + modifier = Modifier, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } +} + +@Composable +fun LoadParticipants( + participants: ImmutableList, + baseNote: Note, + accountViewModel: AccountViewModel, + inner: @Composable (ImmutableList) -> Unit, +) { + var participantUsers by remember { + mutableStateOf>( + persistentListOf(), + ) + } + + LaunchedEffect(key1 = participants) { + launch(Dispatchers.IO) { + val hosts = + participants.mapNotNull { part -> + if (part.pubKey != baseNote.author?.pubkeyHex) { + LocalCache.checkGetOrCreateUser(part.pubKey) + } else { + null + } + } + + val hostsAuthor = hosts + (baseNote.author?.let { listOf(it) } ?: emptyList()) + + val topFilter = accountViewModel.account.liveDiscoveryFollowLists.value + + val followingKeySet = + when (topFilter) { + is AuthorsByOutboxTopNavFilter -> topFilter.authors + is MutedAuthorsByOutboxTopNavFilter -> topFilter.authors + is AllFollowsByOutboxTopNavFilter -> topFilter.authors + is SingleCommunityTopNavFilter -> topFilter.authors + is AuthorsByProxyTopNavFilter -> topFilter.authors + is MutedAuthorsByProxyTopNavFilter -> topFilter.authors + is AllFollowsByProxyTopNavFilter -> topFilter.authors + else -> emptySet() + } + + val allParticipants = + ParticipantListBuilder() + .followsThatParticipateOn(baseNote, followingKeySet) + .minus(hostsAuthor) + + val newParticipantUsers = + if (followingKeySet == null) { + val allFollows = accountViewModel.account.kind3FollowList.flow.value.authors + val followingParticipants = + ParticipantListBuilder() + .followsThatParticipateOn(baseNote, allFollows) + .minus(hostsAuthor) + + (hosts + followingParticipants + (allParticipants - followingParticipants)) + .toImmutableList() + } else { + (hosts + allParticipants).toImmutableList() + } + + if (!equalImmutableLists(newParticipantUsers, participantUsers)) { + participantUsers = newParticipantUsers + } + } + } + + inner(participantUsers) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/SubAssemblyHelper.kt new file mode 100644 index 0000000000..16e3cfca26 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/SubAssemblyHelper.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByAllCommunities +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByCommunity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByGeohash +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesGlobal +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun makeLiveActivitiesFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List = + when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterLiveActivitiesByAllCommunities(feedSettings, since, defaultSince) + is AllFollowsTopNavPerRelayFilterSet -> filterLiveActivitiesByFollows(feedSettings, since, defaultSince) + is AuthorsTopNavPerRelayFilterSet -> filterLiveActivitiesByAuthors(feedSettings, since, defaultSince) + is GlobalTopNavPerRelayFilterSet -> filterLiveActivitiesGlobal(feedSettings, since, defaultSince) + is HashtagTopNavPerRelayFilterSet -> filterLiveActivitiesByHashtag(feedSettings, since, defaultSince) + is LocationTopNavPerRelayFilterSet -> filterLiveActivitiesByGeohash(feedSettings, since, defaultSince) + is MutedAuthorsTopNavPerRelayFilterSet -> filterLiveActivitiesByAuthors(feedSettings, since, defaultSince) + is SingleCommunityTopNavPerRelayFilterSet -> filterLiveActivitiesByCommunity(feedSettings, since, defaultSince) + else -> emptyList() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAllCommunities.kt new file mode 100644 index 0000000000..b102e7b24b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAllCommunities.kt @@ -0,0 +1,85 @@ +/** + * 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.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsAllCommunities +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent + +fun filterLiveActivitiesAllCommunities( + relay: NormalizedRelayUrl, + communities: Set, + since: Long? = null, +): List { + val communityList = communities.sorted() + + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to communityList, + "k" to listOf(LiveActivitiesChatMessageEvent.KIND.toString(), LiveActivitiesEvent.KIND.toString()), + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("k" to listOf("5300"), "a" to communityList), + kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterLiveActivitiesByAllCommunities( + communitySet: AllCommunitiesTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterContentDVMsAllCommunities( + relay = it.key, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAuthors.kt new file mode 100644 index 0000000000..4f152e76ab --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAuthors.kt @@ -0,0 +1,104 @@ +/** + * 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.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent + +fun filterLiveActivitiesAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND), + limit = 300, + since = since, + ), + ), + // authors are participating in the live event. + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("p" to authorList), + kinds = listOf(LiveActivitiesEvent.KIND), + limit = 100, + since = since, + ), + ), + ) +} + +fun filterLiveActivitiesByAuthors( + authorSet: AuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterLiveActivitiesAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterLiveActivitiesByAuthors( + authorSet: MutedAuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterLiveActivitiesAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByCommunity.kt new file mode 100644 index 0000000000..85dd03f564 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByCommunity.kt @@ -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.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent + +fun filterLiveActivitiesByCommunity( + relay: NormalizedRelayUrl, + community: String, + authors: Set?, + since: Long? = null, +): List { + val authors = authors?.sorted() + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to listOf(community), + "k" to listOf(LiveActivitiesChatMessageEvent.KIND.toString(), LiveActivitiesEvent.KIND.toString()), + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + tags = mapOf("k" to listOf("5300"), "a" to listOf(community)), + kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterLiveActivitiesByCommunity( + communitySet: SingleCommunityTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterLiveActivitiesByCommunity( + relay = it.key, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByFollows.kt new file mode 100644 index 0000000000..c635ff1478 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByFollows.kt @@ -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.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun filterLiveActivitiesByFollows( + followsSet: AllFollowsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (followsSet.set.isEmpty()) return emptyList() + + return followsSet.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + val relay = it.key + + listOfNotNull( + it.value.authors?.let { + filterLiveActivitiesAuthors(relay, it, since) + }, + it.value.geotags?.let { + filterLiveActivitiesByGeohash(relay, it, since) + }, + it.value.hashtags?.let { + filterLiveActivitiesByHashtag(relay, it, since) + }, + it.value.communities?.let { + filterLiveActivitiesAllCommunities(relay, it, since) + }, + ).flatten() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByGeohash.kt new file mode 100644 index 0000000000..7c2d11dbc7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByGeohash.kt @@ -0,0 +1,73 @@ +/** + * 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.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent + +fun filterLiveActivitiesByGeohash( + relay: NormalizedRelayUrl, + geotags: Set, + since: Long? = null, +): List { + if (geotags.isEmpty()) return emptyList() + + val geoHashes = geotags.sorted() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND), + tags = mapOf("g" to geoHashes), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterLiveActivitiesByGeohash( + geoSet: LocationTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (geoSet.set.isEmpty()) return emptyList() + + return geoSet.set + .mapNotNull { + if (it.value.geotags.isEmpty()) { + null + } else { + filterLiveActivitiesByGeohash( + relay = it.key, + geotags = it.value.geotags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByHashtag.kt new file mode 100644 index 0000000000..5367d860b6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByHashtag.kt @@ -0,0 +1,74 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent + +fun filterLiveActivitiesByHashtag( + relay: NormalizedRelayUrl, + hashtags: Set, + since: Long?, +): List { + if (hashtags.isEmpty()) return emptyList() + + val hashtags = hashtags.flatMap(::hashtagAlts).distinct().sorted() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND), + tags = mapOf("t" to hashtags), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterLiveActivitiesByHashtag( + hashSet: HashtagTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (hashSet.set.isEmpty()) return emptyList() + + return hashSet.set + .mapNotNull { relayHashSet -> + if (relayHashSet.value.hashtags.isEmpty()) { + null + } else { + filterLiveActivitiesByHashtag( + relay = relayHashSet.key, + hashtags = relayHashSet.value.hashtags, + since = since?.get(relayHashSet.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesGlobal.kt new file mode 100644 index 0000000000..8932ad9e23 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesGlobal.kt @@ -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.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +fun filterLiveActivitiesGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (relays.set.isEmpty()) return emptyList() + + return relays.set + .map { + val since = since?.get(it.key)?.time ?: defaultSince + listOf( + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = listOf(LiveActivitiesEvent.KIND), + limit = 30, + since = since ?: TimeUtils.oneWeekAgo(), + ), + ), + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = listOf(LiveActivitiesChatMessageEvent.KIND), + limit = 50, + since = since ?: TimeUtils.oneDayAgo(), + ), + ), + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt new file mode 100644 index 0000000000..e1089042f9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt @@ -0,0 +1,243 @@ +/** + * 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.ui.screen.loggedIn.discover.nip72Communities + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment.Companion.BottomStart +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.ParticipantListBuilder +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByProxyTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByProxyTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.ui.layouts.LeftPictureLayout +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner +import com.vitorpamplona.amethyst.ui.note.Gallery +import com.vitorpamplona.amethyst.ui.note.LikeReaction +import com.vitorpamplona.amethyst.ui.note.ZapReaction +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding +import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.RowColSpacing +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Immutable +data class CommunityCard( + val name: String, + val description: String?, + val cover: String?, + val moderators: ImmutableList, +) + +@Composable +fun RenderCommunitiesThumb( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteState by observeNote(baseNote, accountViewModel) + val noteEvent = noteState.note.event as? CommunityDefinitionEvent ?: return + + RenderCommunitiesThumb( + CommunityCard( + name = noteEvent.dTag(), + description = noteEvent.description(), + cover = noteEvent.image()?.imageUrl, + moderators = noteEvent.moderatorKeys().toImmutableList(), + ), + baseNote, + accountViewModel, + nav, + ) +} + +@Composable +fun RenderCommunitiesThumb( + card: CommunityCard, + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + LeftPictureLayout( + onImage = { + card.cover?.let { + Box(contentAlignment = BottomStart) { + AsyncImage( + model = it, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = + Modifier + .fillMaxSize() + .clip(QuoteBorder), + ) + } + } ?: run { DisplayAuthorBanner(baseNote, accountViewModel) } + }, + onTitleRow = { + Text( + text = card.name, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + Spacer(modifier = StdHorzSpacer) + Row( + verticalAlignment = CenterVertically, + horizontalArrangement = RowColSpacing, + ) { + LikeReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav, + ) + } + Spacer(modifier = StdHorzSpacer) + ZapReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + onDescription = { + Text( + text = card.description ?: stringRes(R.string.community_about_topic, card.name), + color = MaterialTheme.colorScheme.grayText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontSize = 14.sp, + lineHeight = 18.sp, + modifier = HalfTopPadding, + ) + }, + onBottomRow = { + LoadModerators(card.moderators, baseNote, accountViewModel) { participantUsers -> + if (participantUsers.isNotEmpty()) { + Gallery(participantUsers, HalfTopPadding, accountViewModel, nav) + } + } + }, + ) +} + +@Composable +fun LoadModerators( + moderators: ImmutableList, + baseNote: Note, + accountViewModel: AccountViewModel, + content: @Composable (ImmutableList) -> Unit, +) { + var participantUsers by remember { + mutableStateOf>( + persistentListOf(), + ) + } + + LaunchedEffect(key1 = moderators) { + launch(Dispatchers.IO) { + val hosts = + moderators.mapNotNull { part -> + if (part != baseNote.author?.pubkeyHex) { + LocalCache.checkGetOrCreateUser(part) + } else { + null + } + } + + val topFilter = accountViewModel.account.liveDiscoveryFollowLists.value + val discoveryTopFilterAuthors = + when (topFilter) { + is AuthorsByOutboxTopNavFilter -> topFilter.authors + is MutedAuthorsByOutboxTopNavFilter -> topFilter.authors + is AllFollowsByOutboxTopNavFilter -> topFilter.authors + is SingleCommunityTopNavFilter -> topFilter.authors + is AuthorsByProxyTopNavFilter -> topFilter.authors + is MutedAuthorsByProxyTopNavFilter -> topFilter.authors + is AllFollowsByProxyTopNavFilter -> topFilter.authors + else -> null + } + + val followingKeySet = discoveryTopFilterAuthors + val allParticipants = + ParticipantListBuilder().followsThatParticipateOn(baseNote, followingKeySet).minus(hosts) + + val newParticipantUsers = + if (followingKeySet == null) { + val allFollows = accountViewModel.account.kind3FollowList.flow.value.authors + val followingParticipants = + ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).minus(hosts) + + (hosts + followingParticipants + (allParticipants - followingParticipants)) + .toImmutableList() + } else { + (hosts + allParticipants).toImmutableList() + } + + if (!equalImmutableLists(newParticipantUsers, participantUsers)) { + participantUsers = newParticipantUsers + } + } + } + + content(participantUsers) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverCommunityFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/DiscoverCommunityFeedFilter.kt similarity index 82% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverCommunityFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/DiscoverCommunityFeedFilter.kt index 6737d99be9..6d28ea78dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverCommunityFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/DiscoverCommunityFeedFilter.kt @@ -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,14 +18,17 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent @@ -34,26 +37,26 @@ open class DiscoverCommunityFeedFilter( ) : AdditiveFeedFilter() { override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultDiscoveryFollowList.value + override fun limit() = 150 + override fun showHiddenKey(): Boolean = account.settings.defaultDiscoveryFollowList.value == - PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + PeopleListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) || account.settings.defaultDiscoveryFollowList.value == - MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + MuteListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) override fun feed(): List { val filterParams = FilterByListParams.create( - userHex = account.userProfile().pubkeyHex, - selectedListName = account.settings.defaultDiscoveryFollowList.value, followLists = account.liveDiscoveryFollowLists.value, - hiddenUsers = account.flowHiddenUsers.value, + hiddenUsers = account.hiddenUsers.flow.value, ) // Here we only need to look for CommunityDefinition Events val notes = LocalCache.addressables.mapNotNullIntoSet { key, note -> val noteEvent = note.event - if (noteEvent == null && shouldInclude(Address.parse(key), filterParams)) { + if (noteEvent == null && shouldInclude(key, filterParams, note.relays)) { // send unloaded communities to the screen note } else if (noteEvent is CommunityDefinitionEvent && filterParams.match(noteEvent)) { @@ -72,10 +75,8 @@ open class DiscoverCommunityFeedFilter( // here, we need to look for CommunityDefinition in new collection AND new CommunityDefinition from Post Approvals val filterParams = FilterByListParams.create( - userHex = account.userProfile().pubkeyHex, - selectedListName = account.settings.defaultDiscoveryFollowList.value, followLists = account.liveDiscoveryFollowLists.value, - hiddenUsers = account.flowHiddenUsers.value, + hiddenUsers = account.hiddenUsers.flow.value, ) return collection @@ -89,7 +90,7 @@ open class DiscoverCommunityFeedFilter( val definitionNote = LocalCache.getOrCreateAddressableNote(it) val definitionEvent = definitionNote.event - if (definitionEvent == null && shouldInclude(it, filterParams)) { + if (definitionEvent == null && shouldInclude(it, filterParams, definitionNote.relays)) { definitionNote } else if (definitionEvent is CommunityDefinitionEvent && filterParams.match(definitionEvent)) { definitionNote @@ -107,7 +108,8 @@ open class DiscoverCommunityFeedFilter( private fun shouldInclude( aTag: Address?, params: FilterByListParams, - ) = aTag != null && aTag.kind == CommunityDefinitionEvent.KIND && params.match(aTag) + comingFrom: List = emptyList(), + ) = aTag != null && aTag.kind == CommunityDefinitionEvent.KIND && params.match(aTag, comingFrom) override fun sort(collection: Set): List { val lastNote = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/SubAssemblyHelper.kt new file mode 100644 index 0000000000..77316bb8f0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/SubAssemblyHelper.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunitiesByAllCommunities +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunitiesByAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunitiesByCommunity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunitiesByFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunitiesByGeohash +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunitiesByHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunitiesGlobal +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun makeCommunitiesFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List = + when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterCommunitiesByAllCommunities(feedSettings, since, defaultSince) + is AllFollowsTopNavPerRelayFilterSet -> filterCommunitiesByFollows(feedSettings, since, defaultSince) + is AuthorsTopNavPerRelayFilterSet -> filterCommunitiesByAuthors(feedSettings, since, defaultSince) + is GlobalTopNavPerRelayFilterSet -> filterCommunitiesGlobal(feedSettings, since, defaultSince) + is HashtagTopNavPerRelayFilterSet -> filterCommunitiesByHashtag(feedSettings, since, defaultSince) + is LocationTopNavPerRelayFilterSet -> filterCommunitiesByGeohash(feedSettings, since, defaultSince) + is MutedAuthorsTopNavPerRelayFilterSet -> filterCommunitiesByAuthors(feedSettings, since, defaultSince) + is SingleCommunityTopNavPerRelayFilterSet -> filterCommunitiesByCommunity(feedSettings, since, defaultSince) + else -> emptyList() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAllCommunities.kt new file mode 100644 index 0000000000..7c2112abf1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAllCommunities.kt @@ -0,0 +1,79 @@ +/** + * 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.ui.screen.loggedIn.discover.nip72Communities.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent + +fun filterCommunitiesAllCommunities( + relay: NormalizedRelayUrl, + communities: Set, + since: Long? = null, +): List { + val communityList = communities.sorted() + + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + ids = communityList, + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("a" to communityList), + kinds = listOf(ClassifiedsEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterCommunitiesByAllCommunities( + communitySet: AllCommunitiesTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterCommunitiesAllCommunities( + relay = it.key, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAuthors.kt new file mode 100644 index 0000000000..934aaba585 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAuthors.kt @@ -0,0 +1,93 @@ +/** + * 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.ui.screen.loggedIn.discover.nip72Communities.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent + +fun filterCommunitiesAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterCommunitiesByAuthors( + authorSet: AuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterCommunitiesAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterCommunitiesByAuthors( + authorSet: MutedAuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterCommunitiesAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByCommunity.kt new file mode 100644 index 0000000000..fe3872fd80 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByCommunity.kt @@ -0,0 +1,69 @@ +/** + * 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.ui.screen.loggedIn.discover.nip72Communities.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent + +fun filterClassifiedsByCommunity( + relay: NormalizedRelayUrl, + community: String, + authors: Set?, + since: Long? = null, +): List { + val authors = authors?.sorted() + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + kinds = listOf(CommunityDefinitionEvent.KIND), + ids = listOf(community), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterCommunitiesByCommunity( + communitySet: SingleCommunityTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterClassifiedsByCommunity( + relay = it.key, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByFollows.kt new file mode 100644 index 0000000000..106dbfe5e0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByFollows.kt @@ -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.ui.screen.loggedIn.discover.nip72Communities.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun filterCommunitiesByFollows( + followsSet: AllFollowsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (followsSet.set.isEmpty()) return emptyList() + + return followsSet.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + val relay = it.key + + listOfNotNull( + it.value.authors?.let { + filterCommunitiesAuthors(relay, it, since) + }, + it.value.geotags?.let { + filterCommunitiesByGeohash(relay, it, since) + }, + it.value.hashtags?.let { + filterCommunitiesByHashtag(relay, it, since) + }, + it.value.communities?.let { + filterCommunitiesAllCommunities(relay, it, since) + }, + ).flatten() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByGeohash.kt new file mode 100644 index 0000000000..6c9939b580 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByGeohash.kt @@ -0,0 +1,73 @@ +/** + * 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.ui.screen.loggedIn.discover.nip72Communities.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent + +fun filterCommunitiesByGeohash( + relay: NormalizedRelayUrl, + geotags: Set, + since: Long? = null, +): List { + if (geotags.isEmpty()) return emptyList() + + val geoHashes = geotags.sorted() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), + tags = mapOf("g" to geoHashes), + limit = 100, + since = since, + ), + ), + ) +} + +fun filterCommunitiesByGeohash( + geoSet: LocationTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (geoSet.set.isEmpty()) return emptyList() + + return geoSet.set + .mapNotNull { + if (it.value.geotags.isEmpty()) { + null + } else { + filterCommunitiesByGeohash( + relay = it.key, + geotags = it.value.geotags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByHashtag.kt new file mode 100644 index 0000000000..0a20b39fde --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByHashtag.kt @@ -0,0 +1,74 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent + +fun filterCommunitiesByHashtag( + relay: NormalizedRelayUrl, + hashtags: Set?, + since: Long?, +): List { + if (hashtags == null || hashtags.isEmpty()) return emptyList() + + val hashtags = hashtags.flatMap(::hashtagAlts).distinct().sorted() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), + tags = mapOf("t" to hashtags), + limit = 100, + since = since, + ), + ), + ) +} + +fun filterCommunitiesByHashtag( + hashSet: HashtagTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (hashSet.set.isEmpty()) return emptyList() + + return hashSet.set + .mapNotNull { relayHashSet -> + if (relayHashSet.value.hashtags.isEmpty()) { + null + } else { + filterCommunitiesByHashtag( + relay = relayHashSet.key, + hashtags = relayHashSet.value.hashtags, + since = since?.get(relayHashSet.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesGlobal.kt new file mode 100644 index 0000000000..6798e81f6b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesGlobal.kt @@ -0,0 +1,61 @@ +/** + * 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.ui.screen.loggedIn.discover.nip72Communities.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +fun filterCommunitiesGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (relays.set.isEmpty()) return emptyList() + + return relays.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + listOf( + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = listOf(CommunityDefinitionEvent.KIND), + limit = 100, + since = since, + ), + ), + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = listOf(CommunityPostApprovalEvent.KIND), + limit = 100, + since = since ?: TimeUtils.oneWeekAgo(), + ), + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt new file mode 100644 index 0000000000..34a00ce158 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt @@ -0,0 +1,220 @@ +/** + * 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.ui.screen.loggedIn.discover.nip90DVMs + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Alignment.Companion.BottomStart +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage +import com.vitorpamplona.amethyst.ui.layouts.LeftPictureLayout +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.LikeReaction +import com.vitorpamplona.amethyst.ui.note.ZapReaction +import com.vitorpamplona.amethyst.ui.note.elements.BannerImage +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.observeAppDefinition +import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding +import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp +import com.vitorpamplona.amethyst.ui.theme.SimpleImageBorder +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.bitcoinColor +import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.amethyst.ui.theme.nip05 + +@Immutable +data class DVMCard( + val name: String, + val description: String?, + val cover: String?, + val amount: String?, + val personalized: Boolean?, +) + +@Composable +fun RenderContentDVMThumb( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + // downloads user metadata to pre-load the NIP-65 relays. + baseNote.author?.let { UserFinderFilterAssemblerSubscription(it, accountViewModel) } + val card = observeAppDefinition(appDefinitionNote = baseNote, accountViewModel) + + LeftPictureLayout( + imageFraction = 0.20f, + onImage = { + card.cover?.let { + Box(contentAlignment = BottomStart) { + MyAsyncImage( + imageUrl = it, + contentDescription = card.name, + contentScale = ContentScale.Crop, + mainImageModifier = Modifier, + loadedImageModifier = SimpleImageBorder, + accountViewModel = accountViewModel, + onLoadingBackground = { + baseNote.author?.let { author -> + BannerImage(author, SimpleImageBorder, accountViewModel) + } + }, + onError = { + baseNote.author?.let { author -> + BannerImage(author, SimpleImageBorder, accountViewModel) + } + }, + ) + } + } ?: run { + baseNote.author?.let { author -> + BannerImage(author, SimpleImageBorder, accountViewModel) + } + } + }, + onTitleRow = { + Text( + text = card.name, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = StdVertSpacer) + Row( + verticalAlignment = CenterVertically, + horizontalArrangement = RowColSpacing5dp, + ) { + LikeReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav, + ) + } + Spacer(modifier = StdHorzSpacer) + ZapReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + onDescription = { + card.description?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.grayText, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + fontSize = 14.sp, + lineHeight = 16.sp, + modifier = HalfTopPadding, + ) + } + }, + onBottomRow = { + card.amount?.let { + var color = Color.DarkGray + var amount = it + if (card.amount == "free" || card.amount == "0") { + color = MaterialTheme.colorScheme.secondary + amount = "Free" + } else if (card.amount == "flexible") { + color = MaterialTheme.colorScheme.primaryContainer + amount = "Flexible" + } else if (card.amount == "") { + color = MaterialTheme.colorScheme.grayText + amount = "Unknown" + } else { + color = MaterialTheme.colorScheme.primary + amount = card.amount + " Sats" + } + Row( + verticalAlignment = CenterVertically, + horizontalArrangement = Arrangement.Absolute.Right, + ) { + Text( + textAlign = TextAlign.End, + text = " $amount ", + color = color, + maxLines = 3, + modifier = + Modifier + .weight(1f, fill = false) + .border(Dp(.1f), color, shape = RoundedCornerShape(20)), + fontSize = 12.sp, + ) + } + } + Spacer(modifier = StdHorzSpacer) + card.personalized?.let { + var color = Color.DarkGray + var name = "generic" + if (card.personalized == true) { + color = MaterialTheme.colorScheme.bitcoinColor + name = "Personalized" + } else { + color = MaterialTheme.colorScheme.nip05 + name = "Generic" + } + Spacer(modifier = StdVertSpacer) + Row( + verticalAlignment = CenterVertically, + horizontalArrangement = Arrangement.Absolute.Right, + ) { + Text( + textAlign = TextAlign.End, + text = " $name ", + color = color, + maxLines = 3, + modifier = + Modifier + .padding(start = 4.dp) + .weight(1f, fill = false) + .border(Dp(.1f), color, shape = RoundedCornerShape(20)), + fontSize = 12.sp, + ) + } + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverNIP89FeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DiscoverNIP89FeedFilter.kt similarity index 83% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverNIP89FeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DiscoverNIP89FeedFilter.kt index 6fc4cf22c0..2a697c56cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverNIP89FeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DiscoverNIP89FeedFilter.kt @@ -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,13 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -36,11 +38,13 @@ open class DiscoverNIP89FeedFilter( override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList() + override fun limit() = 50 + open fun followList(): String = account.settings.defaultDiscoveryFollowList.value override fun showHiddenKey(): Boolean = - followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - followList() == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + followList() == PeopleListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) || + followList() == MuteListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) override fun feed(): List { val notes = @@ -55,10 +59,8 @@ open class DiscoverNIP89FeedFilter( fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( - account.userProfile().pubkeyHex, - account.settings.defaultDiscoveryFollowList.value, account.liveDiscoveryFollowLists.value, - account.flowHiddenUsers.value, + account.hiddenUsers.flow.value, ) fun acceptDVM(note: Note): Boolean { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/SubAssemblyHelper.kt new file mode 100644 index 0000000000..cd0e1de3c1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/SubAssemblyHelper.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByAllCommunities +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByCommunity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByGeohash +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsGlobal +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun makeContentDVMsFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long?, +): List = + when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterContentDVMsByAllCommunities(feedSettings, since, defaultSince) + is AllFollowsTopNavPerRelayFilterSet -> filterContentDVMsByFollows(feedSettings, since, defaultSince) + is AuthorsTopNavPerRelayFilterSet -> filterContentDVMsByAuthors(feedSettings, since, defaultSince) + is GlobalTopNavPerRelayFilterSet -> filterContentDVMsGlobal(feedSettings, since, defaultSince) + is HashtagTopNavPerRelayFilterSet -> filterContentDVMsByHashtag(feedSettings, since, defaultSince) + is LocationTopNavPerRelayFilterSet -> filterContentDVMsByGeohash(feedSettings, since, defaultSince) + is MutedAuthorsTopNavPerRelayFilterSet -> filterContentDVMsByAuthors(feedSettings, since, defaultSince) + is SingleCommunityTopNavPerRelayFilterSet -> filterContentDVMsByCommunity(feedSettings, since, defaultSince) + else -> emptyList() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAllCommunities.kt new file mode 100644 index 0000000000..89dfd866b7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAllCommunities.kt @@ -0,0 +1,83 @@ +/** + * 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.ui.screen.loggedIn.discover.nip90DVMs.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent + +fun filterContentDVMsAllCommunities( + relay: NormalizedRelayUrl, + communities: Set, + since: Long? = null, +): List { + val communityList = communities.sorted() + + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to communityList, + "k" to listOf(AppDefinitionEvent.KIND.toString()), + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("k" to listOf("5300"), "a" to communityList), + kinds = listOf(AppDefinitionEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterContentDVMsByAllCommunities( + communitySet: AllCommunitiesTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterContentDVMsAllCommunities( + relay = it.key, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAuthors.kt new file mode 100644 index 0000000000..657367a957 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAuthors.kt @@ -0,0 +1,94 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import kotlin.collections.flatten + +fun filterContentDVMsAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = listOf(AppDefinitionEvent.KIND), + tags = mapOf("k" to listOf("5300")), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterContentDVMsByAuthors( + authorSet: AuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterContentDVMsAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterContentDVMsByAuthors( + authorSet: MutedAuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterContentDVMsAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByCommunity.kt new file mode 100644 index 0000000000..82c25e9b60 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByCommunity.kt @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent + +fun filterContentDVMsByCommunity( + relay: NormalizedRelayUrl, + community: String, + authors: Set?, + since: Long? = null, +): List { + val authors = authors?.sorted() + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to listOf(community), + "k" to listOf(AppDefinitionEvent.KIND.toString()), + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + tags = mapOf("k" to listOf("5300"), "a" to listOf(community)), + kinds = listOf(AppDefinitionEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterContentDVMsByCommunity( + communitySet: SingleCommunityTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterContentDVMsByCommunity( + relay = it.key, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByFollows.kt new file mode 100644 index 0000000000..818c7401d4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByFollows.kt @@ -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.ui.screen.loggedIn.discover.nip90DVMs.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun filterContentDVMsByFollows( + followsSet: AllFollowsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (followsSet.set.isEmpty()) return emptyList() + + return followsSet.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + val relay = it.key + + listOfNotNull( + it.value.authors?.let { + filterContentDVMsAuthors(relay, it, since) + }, + it.value.geotags?.let { + filterContentDVMsByGeohash(relay, it, since) + }, + it.value.hashtags?.let { + filterContentDVMsByHashtag(relay, it, since) + }, + it.value.communities?.let { + filterContentDVMsAllCommunities(relay, it, since) + }, + ).flatten() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByGeohash.kt new file mode 100644 index 0000000000..55bd2db38d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByGeohash.kt @@ -0,0 +1,72 @@ +/** + * 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.ui.screen.loggedIn.discover.nip90DVMs.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent + +fun filterContentDVMsByGeohash( + relay: NormalizedRelayUrl, + geotags: Set, + since: Long? = null, +): List { + if (geotags.isEmpty()) return emptyList() + + val geoHashes = geotags.sorted() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(AppDefinitionEvent.KIND), + tags = mapOf("k" to listOf("5300"), "g" to geoHashes), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterContentDVMsByGeohash( + geoSet: LocationTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (geoSet.set.isEmpty()) return emptyList() + + return geoSet.set + .mapNotNull { + if (it.value.geotags.isEmpty()) { + null + } else { + filterContentDVMsByGeohash( + relay = it.key, + geotags = it.value.geotags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByHashtag.kt new file mode 100644 index 0000000000..2920bd956d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByHashtag.kt @@ -0,0 +1,73 @@ +/** + * 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.ui.screen.loggedIn.discover.nip90DVMs.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent + +fun filterContentDVMsByHashtag( + relay: NormalizedRelayUrl, + hashtags: Set, + since: Long?, +): List { + if (hashtags.isEmpty()) return emptyList() + + val hashtags = hashtags.flatMap(::hashtagAlts).distinct().sorted() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(AppDefinitionEvent.KIND), + tags = mapOf("k" to listOf("5300"), "t" to hashtags), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterContentDVMsByHashtag( + hashSet: HashtagTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (hashSet.set.isEmpty()) return emptyList() + + return hashSet.set + .mapNotNull { relayHashSet -> + if (relayHashSet.value.hashtags.isEmpty()) { + null + } else { + filterContentDVMsByHashtag( + relay = relayHashSet.key, + hashtags = relayHashSet.value.hashtags, + since = since?.get(relayHashSet.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsGlobal.kt new file mode 100644 index 0000000000..395ca8d121 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsGlobal.kt @@ -0,0 +1,51 @@ +/** + * 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.ui.screen.loggedIn.discover.nip90DVMs.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent + +fun filterContentDVMsGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (relays.set.isEmpty()) return emptyList() + + return relays.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + listOf( + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = listOf(AppDefinitionEvent.KIND), + tags = mapOf("k" to listOf("5300")), + limit = 30, + since = since, + ), + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/ClassifiedsThumb.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/ClassifiedsThumb.kt new file mode 100644 index 0000000000..df2a99c3a5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/ClassifiedsThumb.kt @@ -0,0 +1,164 @@ +/** + * 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.ui.screen.loggedIn.discover.nip99Classifieds + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment.Companion.BottomStart +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner +import com.vitorpamplona.amethyst.ui.note.showAmountInteger +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nip99Classifieds.tags.PriceTag + +@Immutable +data class ClassifiedsThumb( + val image: String?, + val title: String?, + val price: PriceTag?, +) + +@Composable +fun RenderClassifiedsThumb( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (baseNote.event !is ClassifiedsEvent) return + + val card by observeNoteAndMap(baseNote, accountViewModel) { + val noteEvent = it.event as? ClassifiedsEvent + ClassifiedsThumb( + image = noteEvent?.image(), + title = noteEvent?.title(), + price = noteEvent?.price(), + ) + } + + InnerRenderClassifiedsThumb(card, baseNote, accountViewModel) +} + +@Preview +@Composable +fun RenderClassifiedsThumbPreview() { + Surface(Modifier.size(200.dp)) { + InnerRenderClassifiedsThumb( + card = + ClassifiedsThumb( + image = null, + title = "Like New", + price = PriceTag("800000", "SATS", null), + ), + note = Note("hex"), + mockAccountViewModel(), + ) + } +} + +@Composable +fun InnerRenderClassifiedsThumb( + card: ClassifiedsThumb, + note: Note, + accountViewModel: AccountViewModel, +) { + Box( + Modifier + .fillMaxWidth() + .aspectRatio(1f), + contentAlignment = BottomStart, + ) { + card.image?.let { + AsyncImage( + model = it, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } ?: run { DisplayAuthorBanner(note, accountViewModel, Modifier.fillMaxSize()) } + + Row( + Modifier + .fillMaxWidth() + .background(Color.Black.copy(0.6f)) + .padding(Size5dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + card.title?.let { + Text( + text = it, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = Color.White, + modifier = Modifier.weight(1f), + ) + } + + card.price?.let { + val priceTag = + remember(card) { + val newAmount = it.amount.toBigDecimalOrNull()?.let { showAmountInteger(it) } ?: it.amount + + if (it.frequency != null && it.currency != null) { + "$newAmount ${it.currency}/${it.frequency}" + } else if (it.currency != null) { + "$newAmount ${it.currency}" + } else { + newAmount + } + } + + Text( + text = priceTag, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = Color.White, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverMarketplaceFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/DiscoverMarketplaceFeedFilter.kt similarity index 80% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverMarketplaceFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/DiscoverMarketplaceFeedFilter.kt index 581f03a564..66793384a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverMarketplaceFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/DiscoverMarketplaceFeedFilter.kt @@ -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,13 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent open class DiscoverMarketplaceFeedFilter( @@ -34,9 +36,11 @@ open class DiscoverMarketplaceFeedFilter( open fun followList(): String = account.settings.defaultDiscoveryFollowList.value + override fun limit() = 150 + override fun showHiddenKey(): Boolean = - followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - followList() == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + followList() == PeopleListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) || + followList() == MuteListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) override fun feed(): List { val params = buildFilterParams(account) @@ -54,10 +58,8 @@ open class DiscoverMarketplaceFeedFilter( fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( - account.userProfile().pubkeyHex, - account.settings.defaultDiscoveryFollowList.value, account.liveDiscoveryFollowLists.value, - account.flowHiddenUsers.value, + account.hiddenUsers.flow.value, ) protected open fun innerApplyFilter(collection: Collection): Set { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt new file mode 100644 index 0000000000..25f64e65f1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt @@ -0,0 +1,395 @@ +/** + * 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.ui.screen.loggedIn.discover.nip99Classifieds + +import android.net.Uri +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar +import com.vitorpamplona.amethyst.ui.note.BaseUserPicture +import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer +import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList +import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton +import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest +import com.vitorpamplona.amethyst.ui.note.creators.location.AddGeoHashButton +import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash +import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField +import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews +import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton +import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest +import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.AddZapraiserButton +import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.ZapRaiserRequest +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapTo +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapToButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class) +@Composable +fun NewProductScreen( + message: String? = null, + attachment: Uri? = null, + quote: Note? = null, + draft: Note? = null, + accountViewModel: AccountViewModel, + nav: Nav, +) { + val postViewModel: NewProductViewModel = viewModel() + postViewModel.init(accountViewModel) + + val context = LocalContext.current + + LaunchedEffect(Unit) { + postViewModel.reloadRelaySet() + draft?.let { + postViewModel.editFromDraft(it) + } + quote?.let { + postViewModel.quote(it) + } + message?.ifBlank { null }?.let { + postViewModel.updateMessage(TextFieldValue(it)) + } + attachment?.let { + withContext(Dispatchers.IO) { + val mediaType = context.contentResolver.getType(it) + postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) + } + } + } + + NewProductScreen( + postViewModel, + accountViewModel, + nav, + ) +} + +@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class) +@Composable +fun NewProductScreen( + postViewModel: NewProductViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchAndLoadMyEmojiList(accountViewModel) + + Scaffold( + topBar = { + PostingTopBar( + titleRes = R.string.new_product, + isActive = postViewModel::canPost, + onCancel = { + try { + accountViewModel.viewModelScope.launch(Dispatchers.IO) { + postViewModel.sendDraftSync() + nav.popBack() + postViewModel.cancel() + } + } catch (e: SignerExceptions.ReadOnlyException) { + // do nothing. + } + }, + onPost = { + try { + accountViewModel.viewModelScope.launch(Dispatchers.IO) { + postViewModel.sendPostSync() + nav.popBack() + postViewModel.cancel() + } + } catch (e: SignerExceptions.ReadOnlyException) { + accountViewModel.toastManager.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_sign_events, + ) + } + }, + ) + }, + ) { pad -> + Surface( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .imePadding(), + ) { + NewProductBody( + postViewModel, + accountViewModel, + nav, + ) + } + } +} + +@Composable +private fun NewProductBody( + postViewModel: NewProductViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val scrollState = rememberScrollState() + + Column(Modifier.fillMaxSize()) { + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = Size10dp) + .weight(1f) + .verticalScroll(scrollState), + ) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + SellProduct(postViewModel = postViewModel) + } + + Row( + modifier = Modifier.padding(vertical = Size10dp), + ) { + BaseUserPicture( + accountViewModel.userProfile(), + Size35dp, + accountViewModel = accountViewModel, + ) + MessageField(R.string.description, postViewModel) + } + + DisplayPreviews(postViewModel.urlPreviews, accountViewModel, nav) + + if (postViewModel.wantsToMarkAsSensitive) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + ContentSensitivityExplainer() + } + } + + if (postViewModel.wantsToAddGeoHash) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + LocationAsHash(postViewModel) + } + } + + if (postViewModel.wantsForwardZapTo) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(top = Size5dp, bottom = Size5dp, start = Size10dp), + ) { + ForwardZapTo(postViewModel, accountViewModel) + } + } + + postViewModel.multiOrchestrator?.let { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + val context = LocalContext.current + + ImageVideoDescription( + uris = it, + defaultServer = accountViewModel.account.settings.defaultFileServer, + includeNIP95 = false, + onAdd = { alt, server, sensitiveContent, mediaQuality -> + postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context) + if (server.type != ServerType.NIP95) { + accountViewModel.account.settings.changeDefaultFileServer(server) + } + }, + onDelete = postViewModel::deleteMediaToUpload, + onCancel = { postViewModel.multiOrchestrator = null }, + accountViewModel = accountViewModel, + ) + } + } + + if (postViewModel.wantsInvoice) { + postViewModel.lnAddress()?.let { lud16 -> + InvoiceRequest( + lud16, + accountViewModel.account.userProfile(), + accountViewModel, + stringRes(id = R.string.lightning_invoice), + stringRes(id = R.string.lightning_create_and_add_invoice), + onNewInvoice = { + postViewModel.insertAtCursor(it) + postViewModel.wantsInvoice = false + }, + onError = { title, message -> accountViewModel.toastManager.toast(title, message) }, + ) + } + } + + if (postViewModel.wantsSecretEmoji) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + Column(Modifier.fillMaxWidth()) { + SecretEmojiRequest { + postViewModel.insertAtCursor(it) + postViewModel.wantsSecretEmoji = false + } + } + } + } + + if (postViewModel.wantsZapraiser && postViewModel.hasLnAddress()) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + ZapRaiserRequest( + stringRes(id = R.string.zapraiser), + postViewModel, + ) + } + } + } + + postViewModel.userSuggestions?.let { + ShowUserSuggestionList( + it, + postViewModel::autocompleteWithUser, + accountViewModel, + modifier = Modifier.heightIn(0.dp, 300.dp), + ) + } + + postViewModel.emojiSuggestions?.let { + ShowEmojiSuggestionList( + it, + postViewModel::autocompleteWithEmoji, + postViewModel::autocompleteWithEmojiUrl, + accountViewModel, + modifier = Modifier.heightIn(0.dp, 300.dp), + ) + } + + BottomRowActions(postViewModel) + } +} + +@Composable +private fun BottomRowActions(postViewModel: NewProductViewModel) { + val scrollState = rememberScrollState() + Row( + modifier = + Modifier + .horizontalScroll(scrollState) + .fillMaxWidth() + .height(50.dp), + verticalAlignment = CenterVertically, + ) { + SelectFromGallery( + isUploading = postViewModel.isUploadingImage, + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier, + ) { + postViewModel.selectImage(it) + } + + TakePictureButton( + onPictureTaken = { + postViewModel.selectImage(it) + }, + ) + + ForwardZapToButton(postViewModel.wantsForwardZapTo) { + postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo + } + + if (postViewModel.canAddZapRaiser) { + AddZapraiserButton(postViewModel.wantsZapraiser) { + postViewModel.wantsZapraiser = !postViewModel.wantsZapraiser + } + } + + MarkAsSensitiveButton(postViewModel.wantsToMarkAsSensitive) { + postViewModel.toggleMarkAsSensitive() + } + + AddGeoHashButton(postViewModel.wantsToAddGeoHash) { + postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash + } + + AddSecretEmojiButton(postViewModel.wantsSecretEmoji) { + postViewModel.wantsSecretEmoji = !postViewModel.wantsSecretEmoji + } + + if (postViewModel.canAddInvoice && postViewModel.hasLnAddress()) { + AddLnInvoiceButton(postViewModel.wantsInvoice) { + postViewModel.wantsInvoice = !postViewModel.wantsInvoice + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt new file mode 100644 index 0000000000..64b834bd75 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt @@ -0,0 +1,648 @@ +/** + * 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.ui.screen.loggedIn.discover.nip99Classifieds + +import android.content.Context +import android.util.Log +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.compose.currentWord +import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor +import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState +import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing +import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState +import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber +import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField +import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewState +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.IZapRaiser +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.IZapField +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.SplitBuilder +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.toZapSplitSetup +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash +import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.references.references +import com.vitorpamplona.quartz.nip10Notes.content.findHashtags +import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris +import com.vitorpamplona.quartz.nip10Notes.content.findURLs +import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes +import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji +import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag +import com.vitorpamplona.quartz.nip30CustomEmoji.emojis +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning +import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive +import com.vitorpamplona.quartz.nip37Drafts.DraftEvent +import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits +import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser +import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount +import com.vitorpamplona.quartz.nip92IMeta.imetas +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nip99Classifieds.ProductImageMeta +import com.vitorpamplona.quartz.nip99Classifieds.image +import com.vitorpamplona.quartz.nip99Classifieds.tags.ConditionTag +import com.vitorpamplona.quartz.nip99Classifieds.tags.PriceTag +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +@Stable +open class NewProductViewModel : + ViewModel(), + ILocationGrabber, + IMessageField, + IZapField, + IZapRaiser { + val draftTag = DraftTagState() + + init { + viewModelScope.launch(Dispatchers.IO) { + draftTag.versions.collectLatest { + // don't save the first + if (it > 0) { + sendDraftSync() + } + } + } + } + + var accountViewModel: AccountViewModel? = null + var account: Account? = null + + var productImages by mutableStateOf>(emptyList()) + val iMetaDescription = IMetaAttachments() + + override var message by mutableStateOf(TextFieldValue("")) + + val urlPreviews = PreviewState() + + var isUploadingImage by mutableStateOf(false) + + var userSuggestions: UserSuggestionState? = null + var userSuggestionsMainMessage: UserSuggestionAnchor? = null + + var emojiSuggestions: EmojiSuggestionState? = null + + // Images and Videos + var multiOrchestrator by mutableStateOf(null) + + // Classifieds + var title by mutableStateOf(TextFieldValue("")) + var price by mutableStateOf(TextFieldValue("")) + var locationText by mutableStateOf(TextFieldValue("")) + var category by mutableStateOf(TextFieldValue("")) + var condition by mutableStateOf(ConditionTag.CONDITION.USED_LIKE_NEW) + + // Invoices + var canAddInvoice by mutableStateOf(false) + var wantsInvoice by mutableStateOf(false) + + var wantsSecretEmoji by mutableStateOf(false) + + // Forward Zap to + var wantsForwardZapTo by mutableStateOf(false) + override var forwardZapTo = mutableStateOf>(SplitBuilder()) + override var forwardZapToEditting = mutableStateOf(TextFieldValue("")) + + // NSFW, Sensitive + var wantsToMarkAsSensitive by mutableStateOf(false) + + // GeoHash + var wantsToAddGeoHash by mutableStateOf(false) + var location: StateFlow? = null + + // ZapRaiser + var canAddZapRaiser by mutableStateOf(false) + var wantsZapraiser by mutableStateOf(false) + override val zapRaiserAmount = mutableStateOf(null) + + var relayList by mutableStateOf?>(null) + + fun lnAddress(): String? = account?.userProfile()?.info?.lnAddress() + + fun hasLnAddress(): Boolean = account?.userProfile()?.info?.lnAddress() != null + + fun user(): User? = account?.userProfile() + + open fun init(accountVM: AccountViewModel) { + this.accountViewModel = accountVM + this.account = accountVM.account + this.canAddInvoice = hasLnAddress() + this.canAddZapRaiser = hasLnAddress() + + this.userSuggestions?.reset() + this.userSuggestions = UserSuggestionState(accountVM) + + this.emojiSuggestions?.reset() + this.emojiSuggestions = EmojiSuggestionState(accountVM) + } + + fun editFromDraft(draft: Note) { + val accountViewModel = accountViewModel ?: return + + val noteEvent = draft.event + val noteAuthor = draft.author + + if (noteEvent is DraftEvent && noteAuthor != null) { + viewModelScope.launch(Dispatchers.IO) { + accountViewModel.createTempDraftNote(noteEvent)?.let { innerNote -> + val oldTag = (draft.event as? AddressableEvent)?.dTag() + if (oldTag != null) { + draftTag.set(oldTag) + } + loadFromDraft(innerNote) + } + } + } + } + + open fun quote(quote: Note) { + val accountViewModel = accountViewModel ?: return + + message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}") + + quote.author?.let { quotedUser -> + if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) { + if (forwardZapTo.value.items.none { it.key.pubkeyHex == quotedUser.pubkeyHex }) { + forwardZapTo.value.addItem(quotedUser) + } + if (forwardZapTo.value.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) { + forwardZapTo.value.addItem(accountViewModel.userProfile()) + } + + val pos = forwardZapTo.value.items.indexOfFirst { it.key.pubkeyHex == quotedUser.pubkeyHex } + forwardZapTo.value.updatePercentage(pos, 0.9f) + } + } + + if (!forwardZapTo.value.items.isEmpty()) { + wantsForwardZapTo = true + } + + urlPreviews.update(message) + } + + private fun loadFromDraft(draft: Note) { + val draftEvent = draft.event ?: return + if (draftEvent !is ClassifiedsEvent) return + + loadFromDraft(draftEvent) + } + + private fun loadFromDraft(draftEvent: ClassifiedsEvent) { + val localfowardZapTo = draftEvent.tags.filter { it.size > 1 && it[0] == "zap" } + forwardZapTo.value = SplitBuilder() + localfowardZapTo.forEach { + val user = LocalCache.getOrCreateUser(it[1]) + val value = it.last().toFloatOrNull() ?: 0f + forwardZapTo.value.addItem(user, value) + } + forwardZapToEditting.value = TextFieldValue("") + wantsForwardZapTo = localfowardZapTo.isNotEmpty() + + wantsToMarkAsSensitive = draftEvent.isSensitive() + + val geohash = draftEvent.getGeoHash() + wantsToAddGeoHash = geohash != null + + val zapraiser = draftEvent.zapraiserAmount() + wantsZapraiser = zapraiser != null + zapRaiserAmount.value = null + if (zapraiser != null) { + zapRaiserAmount.value = zapraiser + } + + title = TextFieldValue(draftEvent.title() ?: "") + price = TextFieldValue(draftEvent.price()?.amount ?: "") + category = TextFieldValue(draftEvent.categories().firstOrNull() ?: "") + locationText = TextFieldValue(draftEvent.location() ?: "") + condition = draftEvent.conditionValid() ?: ConditionTag.CONDITION.USED_LIKE_NEW + + val imageSet = draftEvent.images().toMutableSet() + + draftEvent.imetas().forEach { + if (it.url in imageSet) { + productImages = productImages + ProductImageMeta.parse(it) + imageSet.remove(it.url) + } else { + iMetaDescription.add(it) + } + } + + imageSet.forEach { + productImages = productImages + ProductImageMeta(it) + } + + message = TextFieldValue(draftEvent.content) + + urlPreviews.update(message) + } + + suspend fun sendPostSync() { + val template = createTemplate() ?: return + + accountViewModel?.account?.signAndSendPrivatelyOrBroadcast( + template, + relayList = { relayList }, + ) + + accountViewModel?.deleteDraft(draftTag.current) + + cancel() + } + + suspend fun sendDraftSync() { + val accountViewModel = accountViewModel ?: return + + if (message.text.isBlank()) { + accountViewModel.account.deleteDraft(draftTag.current) + } else { + val template = createTemplate() ?: return + accountViewModel.account.createAndSendDraft(draftTag.current, template) + } + } + + private suspend fun createTemplate(): EventTemplate? { + val accountViewModel = accountViewModel ?: return null + + val tagger = + NewMessageTagger( + message = message.text, + dao = accountViewModel, + ) + tagger.run() + + val emojis = findEmoji(tagger.message, account?.emoji?.myEmojis?.value) + val urls = findURLs(tagger.message) + val usedAttachments = iMetaDescription.filterIsIn(urls.toSet()) + productImages.map { it.toIMeta() } + + val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() + + val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null + val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null + val contentWarningReason = if (wantsToMarkAsSensitive) "" else null + + val quotes = findNostrUris(tagger.message) + + val template = + ClassifiedsEvent.build( + title.text, + PriceTag(price.text, "SATS", null), + tagger.message, + locationText.text.ifBlank { null }, + condition, + ) { + productImages.forEach { image(it.url) } + + hashtags(listOfNotNull(category.text.ifBlank { null }) + findHashtags(tagger.message)) + quotes(quotes) + + geoHash?.let { geohash(it) } + localZapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + contentWarningReason?.let { contentWarning(it) } + + emojis(emojis) + imetas(usedAttachments) + references(urls) + } + + return template + } + + fun findEmoji( + message: String, + myEmojiSet: List?, + ): List { + if (myEmojiSet == null) return emptyList() + return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> + myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link.url) } + } + } + + fun upload( + alt: String?, + contentWarningReason: String?, + mediaQuality: Int, + server: ServerName, + onError: (title: String, message: String) -> Unit, + context: Context, + ) { + viewModelScope.launch(Dispatchers.Default) { + val myAccount = account ?: return@launch + val myMultiOrchestrator = multiOrchestrator ?: return@launch + + isUploadingImage = true + + val results = + myMultiOrchestrator.upload( + alt, + contentWarningReason, + MediaCompressor.intToCompressorQuality(mediaQuality), + server, + myAccount, + context, + ) + + if (results.allGood) { + results.successful.forEach { + if (it.result is UploadOrchestrator.OrchestratorResult.ServerResult) { + if (it.result.fileHeader.mimeType + ?.startsWith("image") == true + ) { + productImages = productImages + + ProductImageMeta( + it.result.url, + it.result.fileHeader.mimeType, + it.result.fileHeader.blurHash + ?.blurhash, + it.result.fileHeader.dim, + alt, + it.result.fileHeader.hash, + it.result.fileHeader.size, + ) + } else { + iMetaDescription.add(it.result, alt, contentWarningReason) + + message = message.insertUrlAtCursor(it.result.url) + urlPreviews.update(message) + } + } + } + + multiOrchestrator = null + } else { + val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct() + + onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) + } + + isUploadingImage = false + } + } + + open fun cancel() { + draftTag.rotate() + + message = TextFieldValue("") + + multiOrchestrator = null + isUploadingImage = false + + wantsInvoice = false + wantsZapraiser = false + zapRaiserAmount.value = null + + condition = ConditionTag.CONDITION.USED_LIKE_NEW + locationText = TextFieldValue("") + title = TextFieldValue("") + category = TextFieldValue("") + price = TextFieldValue("") + + wantsForwardZapTo = false + wantsToMarkAsSensitive = false + wantsToAddGeoHash = false + wantsSecretEmoji = false + + forwardZapTo.value = SplitBuilder() + forwardZapToEditting.value = TextFieldValue("") + + urlPreviews.reset() + + userSuggestions?.reset() + userSuggestionsMainMessage = null + + productImages = emptyList() + iMetaDescription.reset() + + emojiSuggestions?.reset() + + reloadRelaySet() + } + + fun reloadRelaySet() { + val account = accountViewModel?.account ?: return + + relayList = + account.outboxRelays.flow.value + .toImmutableList() + } + + fun deleteMediaToUpload(selected: SelectedMediaProcessing) { + this.multiOrchestrator?.remove(selected) + } + + override fun updateMessage(it: TextFieldValue) { + message = it + urlPreviews.update(message) + + if (message.selection.collapsed) { + val lastWord = message.currentWord() + + userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE + userSuggestions?.processCurrentWord(lastWord) + + emojiSuggestions?.processCurrentWord(lastWord) + } + + draftTag.newVersion() + } + + override fun updateZapForwardTo(newZapForwardTo: TextFieldValue) { + forwardZapToEditting.value = newZapForwardTo + if (newZapForwardTo.selection.collapsed) { + val lastWord = newZapForwardTo.text + userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS + userSuggestions?.processCurrentWord(lastWord) + } + } + + open fun autocompleteWithUser(item: User) { + userSuggestions?.let { userSuggestions -> + if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { + val lastWord = message.currentWord() + message = userSuggestions.replaceCurrentWord(message, lastWord, item) + urlPreviews.update(message) + } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { + forwardZapTo.value.addItem(item) + forwardZapToEditting.value = TextFieldValue("") + } + + userSuggestionsMainMessage = null + userSuggestions.reset() + } + + draftTag.newVersion() + } + + open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { + val wordToInsert = ":${item.code}:" + + message = message.replaceCurrentWord(wordToInsert) + urlPreviews.update(message) + + emojiSuggestions?.reset() + + draftTag.newVersion() + } + + open fun autocompleteWithEmojiUrl(item: EmojiPackState.EmojiMedia) { + val wordToInsert = item.link.url + " " + + viewModelScope.launch(Dispatchers.IO) { + iMetaDescription.downloadAndPrepare( + item.link.url, + { Amethyst.instance.okHttpClients.getHttpClient(accountViewModel?.account?.privacyState?.shouldUseTorForImageDownload(item.link.url) ?: false) }, + ) + } + + message = message.replaceCurrentWord(wordToInsert) + urlPreviews.update(message) + + emojiSuggestions?.reset() + + draftTag.newVersion() + } + + fun canPost(): Boolean = + message.text.isNotBlank() && + !isUploadingImage && + !wantsInvoice && + (!wantsZapraiser || zapRaiserAmount.value != null) && + title.text.isNotBlank() && + price.text.isNotBlank() && + category.text.isNotBlank() && + multiOrchestrator == null + + fun insertAtCursor(newElement: String) { + message = message.insertUrlAtCursor(newElement) + } + + fun selectImage(uris: ImmutableList) { + multiOrchestrator = MultiOrchestrator(uris) + } + + override fun locationFlow(): StateFlow { + if (location == null) { + location = locationManager().geohashStateFlow + } + + return location!! + } + + override fun onCleared() { + super.onCleared() + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + } + + override fun updateZapPercentage( + index: Int, + sliderValue: Float, + ) { + forwardZapTo.value.updatePercentage(index, sliderValue) + } + + override fun updateZapFromText() { + viewModelScope.launch(Dispatchers.Default) { + val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel!!) + tagger.run() + tagger.pTags?.forEach { taggedUser -> + if (!forwardZapTo.value.items.any { it.key == taggedUser }) { + forwardZapTo.value.addItem(taggedUser) + } + } + } + } + + override fun updateZapRaiserAmount(newAmount: Long?) { + zapRaiserAmount.value = newAmount + draftTag.newVersion() + } + + fun toggleMarkAsSensitive() { + wantsToMarkAsSensitive = !wantsToMarkAsSensitive + draftTag.newVersion() + } + + fun updateTitle(it: TextFieldValue) { + title = it + draftTag.newVersion() + } + + fun updatePrice(it: TextFieldValue) { + runCatching { + if (it.text.isEmpty()) { + price = TextFieldValue("") + } else if (it.text.toLongOrNull() != null) { + price = it + } + } + draftTag.newVersion() + } + + fun updateCondition(newCondition: ConditionTag.CONDITION) { + condition = newCondition + draftTag.newVersion() + } + + fun updateCategory(value: TextFieldValue) { + category = value + draftTag.newVersion() + } + + fun updateLocation(it: TextFieldValue) { + locationText = it + draftTag.newVersion() + } + + override fun locationManager(): LocationState = Amethyst.instance.locationManager +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/products/SellProduct.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt similarity index 85% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/products/SellProduct.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt index 35ff8316f6..8697cf643d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/products/SellProduct.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt @@ -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,12 +18,19 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.creators.products +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds +import android.annotation.SuppressLint +import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme @@ -34,28 +41,63 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Font14SP +import com.vitorpamplona.amethyst.ui.theme.Height100Modifier +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.SquaredQuoteBorderModifier +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip99Classifieds.tags.ConditionTag import kotlinx.collections.immutable.toImmutableList +@SuppressLint("ViewModelConstructorInComposable") +@Preview @Composable -fun SellProduct(postViewModel: NewPostViewModel) { +fun SellProductPreview() { + val accountViewModel = mockAccountViewModel() + val postViewModel = NewProductViewModel() + postViewModel.init(accountViewModel) + + ThemeComparisonColumn { + SellProduct(postViewModel) + } +} + +@Composable +fun SellProduct(postViewModel: NewProductViewModel) { Column( modifier = Modifier.fillMaxWidth(), ) { + if (!postViewModel.productImages.isEmpty()) { + LazyRow(Height100Modifier, horizontalArrangement = spacedBy(Size5dp)) { + items(postViewModel.productImages) { + Box(SquaredQuoteBorderModifier) { + AsyncImage( + model = it.url, + contentDescription = it.alt ?: it.url, + contentScale = ContentScale.FillHeight, + modifier = Modifier.fillMaxHeight().aspectRatio(1f), + ) + } + } + } + } + Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), @@ -233,9 +275,7 @@ fun SellProduct(postViewModel: NewPostViewModel) { categoryTypes.map { TitleExplainer(it.second, null) }.toImmutableList() } TextSpinner( - placeholder = - categoryTypes.filter { it.second == postViewModel.category.text }.firstOrNull()?.second - ?: "", + placeholder = categoryTypes.firstOrNull { it.second == postViewModel.category.text }?.second ?: "", options = categoryOptions, onSelect = { postViewModel.updateCategory(TextFieldValue(categoryTypes[it].second)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SubAssemblyHelper.kt new file mode 100644 index 0000000000..49ca8ca0eb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SubAssemblyHelper.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByAllCommunities +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByCommunity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByGeohash +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsGlobal +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun makeClassifiedsFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List = + when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterClassifiedsByAllCommunities(feedSettings, since, defaultSince) + is AllFollowsTopNavPerRelayFilterSet -> filterClassifiedsByFollows(feedSettings, since, defaultSince) + is AuthorsTopNavPerRelayFilterSet -> filterClassifiedsByAuthors(feedSettings, since, defaultSince) + is GlobalTopNavPerRelayFilterSet -> filterClassifiedsGlobal(feedSettings, since, defaultSince) + is HashtagTopNavPerRelayFilterSet -> filterClassifiedsByHashtag(feedSettings, since, defaultSince) + is LocationTopNavPerRelayFilterSet -> filterClassifiedsByGeohash(feedSettings, since, defaultSince) + is MutedAuthorsTopNavPerRelayFilterSet -> filterClassifiedsByAuthors(feedSettings, since, defaultSince) + is SingleCommunityTopNavPerRelayFilterSet -> filterClassifiedsByCommunity(feedSettings, since, defaultSince) + else -> emptyList() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAllCommunities.kt new file mode 100644 index 0000000000..e1d6664ef2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAllCommunities.kt @@ -0,0 +1,83 @@ +/** + * 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.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent + +fun filterClassifiedsAllCommunities( + relay: NormalizedRelayUrl, + communities: Set, + since: Long? = null, +): List { + val communityList = communities.sorted() + + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to communityList, + "k" to listOf(ClassifiedsEvent.KIND.toString()), + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("a" to communityList), + kinds = listOf(ClassifiedsEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterClassifiedsByAllCommunities( + communitySet: AllCommunitiesTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterClassifiedsAllCommunities( + relay = it.key, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAuthors.kt new file mode 100644 index 0000000000..de27c901a7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAuthors.kt @@ -0,0 +1,92 @@ +/** + * 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.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent + +fun filterClassifiedsAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = listOf(ClassifiedsEvent.KIND), + limit = authorList.size * 10, + since = since, + ), + ), + ) +} + +fun filterClassifiedsByAuthors( + authorSet: AuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterClassifiedsAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterClassifiedsByAuthors( + authorSet: MutedAuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterClassifiedsAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByCommunity.kt new file mode 100644 index 0000000000..cffe1a0042 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByCommunity.kt @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent + +fun filterClassifiedsByCommunity( + relay: NormalizedRelayUrl, + community: String, + authors: Set?, + since: Long? = null, +): List { + val authors = authors?.sorted() + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to listOf(community), + "k" to listOf(ClassifiedsEvent.KIND.toString()), + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + tags = mapOf("a" to listOf(community)), + kinds = listOf(ClassifiedsEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterClassifiedsByCommunity( + communitySet: SingleCommunityTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterClassifiedsByCommunity( + relay = it.key, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByFollows.kt new file mode 100644 index 0000000000..7bbf7535e2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByFollows.kt @@ -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.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun filterClassifiedsByFollows( + followsSet: AllFollowsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (followsSet.set.isEmpty()) return emptyList() + + return followsSet.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + val relay = it.key + + listOfNotNull( + it.value.authors?.let { + filterClassifiedsAuthors(relay, it, since) + }, + it.value.geotags?.let { + filterClassifiedsByGeohash(relay, it, since) + }, + it.value.hashtags?.let { + filterClassifiedsByHashtag(relay, it, since) + }, + it.value.communities?.let { + filterClassifiedsAllCommunities(relay, it, since) + }, + ).flatten() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByGeohash.kt new file mode 100644 index 0000000000..0c89c881b8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByGeohash.kt @@ -0,0 +1,72 @@ +/** + * 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.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent + +fun filterClassifiedsByGeohash( + relay: NormalizedRelayUrl, + geotags: Set, + since: Long? = null, +): List { + if (geotags.isEmpty()) return emptyList() + + val geoHashes = geotags.sorted() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(ClassifiedsEvent.KIND), + tags = mapOf("g" to geoHashes), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterClassifiedsByGeohash( + geoSet: LocationTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (geoSet.set.isEmpty()) return emptyList() + + return geoSet.set + .mapNotNull { + if (it.value.geotags.isEmpty()) { + null + } else { + filterClassifiedsByGeohash( + relay = it.key, + geotags = it.value.geotags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByHashtag.kt new file mode 100644 index 0000000000..9645bef0d3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByHashtag.kt @@ -0,0 +1,73 @@ +/** + * 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.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent + +fun filterClassifiedsByHashtag( + relay: NormalizedRelayUrl, + hashtags: Set?, + since: Long?, +): List? { + if (hashtags == null || hashtags.isEmpty()) return null + + val hashtags = hashtags.flatMap(::hashtagAlts).distinct().sorted() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(ClassifiedsEvent.KIND), + tags = mapOf("t" to hashtags), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterClassifiedsByHashtag( + hashSet: HashtagTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (hashSet.set.isEmpty()) return emptyList() + + return hashSet.set + .mapNotNull { relayHashSet -> + if (relayHashSet.value.hashtags.isEmpty()) { + null + } else { + filterClassifiedsByHashtag( + relay = relayHashSet.key, + hashtags = relayHashSet.value.hashtags, + since = since?.get(relayHashSet.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsGlobal.kt new file mode 100644 index 0000000000..f91c5cde5c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsGlobal.kt @@ -0,0 +1,51 @@ +/** + * 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.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +fun filterClassifiedsGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (relays.set.isEmpty()) return emptyList() + + return relays.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince ?: TimeUtils.oneMonthAgo() + listOf( + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = listOf(ClassifiedsEvent.KIND), + limit = 30, + since = since, + ), + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt index 17707e6f97..ad48a8edb7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt @@ -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 @@ -56,14 +56,14 @@ import com.vitorpamplona.amethyst.ui.components.SwipeToDeleteContainer import com.vitorpamplona.amethyst.ui.feeds.FeedState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys.DRAFTS -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.note.NoteCompose -import com.vitorpamplona.amethyst.ui.screen.NostrDraftEventsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.RenderFeedState import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.dal.DraftEventsFeedViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding @@ -74,10 +74,10 @@ fun DraftListScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val draftFeedViewModel: NostrDraftEventsFeedViewModel = + val draftFeedViewModel: DraftEventsFeedViewModel = viewModel( key = "NostrDraftEventsFeedViewModel", - factory = NostrDraftEventsFeedViewModel.Factory(accountViewModel.account), + factory = DraftEventsFeedViewModel.Factory(accountViewModel.account), ) RenderDraftListScreen(draftFeedViewModel, accountViewModel, nav) @@ -85,7 +85,7 @@ fun DraftListScreen( @Composable private fun RenderDraftListScreen( - feedViewModel: NostrDraftEventsFeedViewModel, + feedViewModel: DraftEventsFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { @@ -199,16 +199,9 @@ private fun DraftFeedLoaded( } } itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item -> - Row( - Modifier - .fillMaxWidth() - .animateItemPlacement(), - ) { + Row(Modifier.fillMaxWidth().animateItem()) { SwipeToDeleteContainer( - modifier = - Modifier - .fillMaxWidth() - .animateContentSize(), + modifier = Modifier.fillMaxWidth().animateContentSize(), onStartToEnd = { accountViewModel.delete(item) }, ) { NoteCompose( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DraftEventsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/dal/DraftEventsFeedFilter.kt similarity index 90% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DraftEventsFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/dal/DraftEventsFeedFilter.kt index 0b42f7c797..8295b5ff94 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DraftEventsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/dal/DraftEventsFeedFilter.kt @@ -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,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.nip37Drafts.DraftEvent class DraftEventsFeedFilter( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/dal/DraftEventsFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/dal/DraftEventsFeedViewModel.kt new file mode 100644 index 0000000000..a0c753bfb7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/dal/DraftEventsFeedViewModel.kt @@ -0,0 +1,39 @@ +/** + * 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.ui.screen.loggedIn.drafts.dal + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.FeedViewModel + +@Stable +class DraftEventsFeedViewModel( + val account: Account, +) : FeedViewModel(DraftEventsFeedFilter(account)) { + class Factory( + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = DraftEventsFeedViewModel(account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt index b6e7321b37..1aefd5a1b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt @@ -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,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -38,15 +39,14 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.BottomStart import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext @@ -56,41 +56,41 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.distinctUntilChanged -import androidx.lifecycle.map import androidx.lifecycle.viewmodel.compose.viewModel -import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.ZapPaymentHandler +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.note.DVMCard -import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.ObserveZapIcon import com.vitorpamplona.amethyst.ui.note.PayViaIntentDialog import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent import com.vitorpamplona.amethyst.ui.note.ZapAmountChoicePopup import com.vitorpamplona.amethyst.ui.note.ZapIcon import com.vitorpamplona.amethyst.ui.note.ZappedIcon +import com.vitorpamplona.amethyst.ui.note.elements.BannerImage import com.vitorpamplona.amethyst.ui.note.elements.customZapClick import com.vitorpamplona.amethyst.ui.note.payViaIntent -import com.vitorpamplona.amethyst.ui.screen.NostrNIP90ContentDiscoveryFeedViewModel import com.vitorpamplona.amethyst.ui.screen.RenderFeedState import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.DVMCard +import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.dal.NIP90ContentDiscoveryFeedViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.ModifierWidth3dp -import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.SimpleImage75Modifier import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size35dp -import com.vitorpamplona.amethyst.ui.theme.Size75dp import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent @@ -117,10 +117,10 @@ fun DvmContentDiscoveryScreen( DvmTopBar(appDefinitionEventId, accountViewModel, nav) }, accountViewModel = accountViewModel, - ) { - Column(Modifier.padding(it)) { - LoadNote(baseNoteHex = appDefinitionEventId, accountViewModel = accountViewModel) { - it?.let { baseNote -> + ) { paddingValues -> + Column(Modifier.padding(paddingValues)) { + LoadNote(baseNoteHex = appDefinitionEventId, accountViewModel = accountViewModel) { note -> + note?.let { baseNote -> WatchNoteEvent( baseNote, onNoteEventFound = { @@ -151,7 +151,7 @@ fun DvmContentDiscoveryScreen( } val onRefresh = { - accountViewModel.requestDVMContentDiscovery(noteAuthor.pubkeyHex) { + accountViewModel.requestDVMContentDiscovery(noteAuthor) { requestEventID = it } } @@ -194,7 +194,8 @@ fun ObserverContentDiscoveryResponse( nav: INav, ) { val noteAuthor = appDefinition.author ?: return - val updateFiltersFromRelays = dvmRequestId.live().metadata.observeAsState() + + EventFinderFilterAssemblerSubscription(dvmRequestId, accountViewModel) val resultFlow = remember(dvmRequestId) { @@ -261,10 +262,10 @@ fun PrepareViewContentDiscoveryModels( accountViewModel: AccountViewModel, nav: INav, ) { - val resultFeedViewModel: NostrNIP90ContentDiscoveryFeedViewModel = + val resultFeedViewModel: NIP90ContentDiscoveryFeedViewModel = viewModel( key = "NostrNIP90ContentDiscoveryFeedViewModel${dvm.pubkeyHex}$dvmRequestId", - factory = NostrNIP90ContentDiscoveryFeedViewModel.Factory(accountViewModel.account, dvmkey = dvm.pubkeyHex, requestid = dvmRequestId), + factory = NIP90ContentDiscoveryFeedViewModel.Factory(accountViewModel.account, dvmKey = dvm.pubkeyHex, requestId = dvmRequestId), ) LaunchedEffect(key1 = dvmRequestId, latestResponse.id) { @@ -276,7 +277,7 @@ fun PrepareViewContentDiscoveryModels( @Composable fun RenderNostrNIP90ContentDiscoveryScreen( - resultFeedViewModel: NostrNIP90ContentDiscoveryFeedViewModel, + resultFeedViewModel: NIP90ContentDiscoveryFeedViewModel, onRefresh: () -> Unit, accountViewModel: AccountViewModel, nav: INav, @@ -320,19 +321,36 @@ fun FeedDVM( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - val card = observeAppDefinition(appDefinitionNote) + val card = observeAppDefinition(appDefinitionNote, accountViewModel) card.cover?.let { - AsyncImage( - model = it, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = - Modifier - .size(Size75dp) - .clip(QuoteBorder), - ) - } ?: run { NoteAuthorPicture(appDefinitionNote, nav, accountViewModel, Size75dp) } + Box(contentAlignment = BottomStart) { + MyAsyncImage( + imageUrl = it, + contentDescription = card.name, + contentScale = ContentScale.Crop, + mainImageModifier = Modifier, + loadedImageModifier = SimpleImage75Modifier, + accountViewModel = accountViewModel, + onLoadingBackground = { + appDefinitionNote.author?.let { author -> + BannerImage(author, SimpleImage75Modifier, accountViewModel) + } + }, + onError = { + appDefinitionNote.author?.let { author -> + BannerImage(author, SimpleImage75Modifier, accountViewModel) + } + }, + ) + } + } ?: run { + appDefinitionNote.author?.let { author -> + Box(contentAlignment = BottomStart) { + BannerImage(author, SimpleImage75Modifier, accountViewModel) + } + } + } Spacer(modifier = DoubleVertSpacer) @@ -358,7 +376,7 @@ fun FeedDVM( if (invoice != null) { val context = LocalContext.current Button(onClick = { - if (accountViewModel.account.hasWalletConnectSetup()) { + if (accountViewModel.account.nip47SignerState.hasWalletConnectSetup()) { accountViewModel.sendZapPaymentRequestFor( bolt11 = invoice, zappedNote = null, @@ -395,7 +413,7 @@ fun FeedDVM( val amountInInvoice = try { LnInvoiceUtil.getAmountInSats(invoice).toLong() - } catch (e: Exception) { + } catch (_: Exception) { null } @@ -447,8 +465,8 @@ fun ZapDVMButton( ) } - // Makes sure the user is loaded to get his ln address - val userState = noteAuthor.live().metadata.observeAsState() + // Makes sure the user is loaded to get his ln address ahead of time. + UserFinderFilterAssemblerSubscription(noteAuthor, accountViewModel) val context = LocalContext.current val scope = rememberCoroutineScope() @@ -530,13 +548,14 @@ fun ZapDVMButton( if (zappingProgress > 0.00001 && zappingProgress < 0.99999) { Spacer(ModifierWidth3dp) + val animatedProgress by animateFloatAsState( + targetValue = zappingProgress, + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, + label = "ZapIconIndicator", + ) + CircularProgressIndicator( - progress = - animateFloatAsState( - targetValue = zappingProgress, - animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, - label = "ZapIconIndicator", - ).value, + progress = { animatedProgress }, modifier = remember { Modifier.size(animationSize) }, strokeWidth = 2.dp, color = grayTint, @@ -587,19 +606,36 @@ fun FeedEmptyWithStatus( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - val card = observeAppDefinition(appDefinitionNote) + val card = observeAppDefinition(appDefinitionNote, accountViewModel) card.cover?.let { - AsyncImage( - model = it, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = - Modifier - .size(Size75dp) - .clip(QuoteBorder), - ) - } ?: run { NoteAuthorPicture(appDefinitionNote, nav, accountViewModel, Size75dp) } + Box(contentAlignment = BottomStart) { + MyAsyncImage( + imageUrl = it, + contentDescription = card.name, + contentScale = ContentScale.Crop, + mainImageModifier = Modifier, + loadedImageModifier = SimpleImage75Modifier, + accountViewModel = accountViewModel, + onLoadingBackground = { + appDefinitionNote.author?.let { author -> + BannerImage(author, SimpleImage75Modifier, accountViewModel) + } + }, + onError = { + appDefinitionNote.author?.let { author -> + BannerImage(author, SimpleImage75Modifier, accountViewModel) + } + }, + ) + } + } ?: run { + appDefinitionNote.author?.let { author -> + Box(contentAlignment = BottomStart) { + BannerImage(author, SimpleImage75Modifier, accountViewModel) + } + } + } Spacer(modifier = DoubleVertSpacer) @@ -639,26 +675,12 @@ fun convertAppMetadataToCard(metadata: AppMetadata?): DVMCard { } @Composable -fun observeAppDefinition(appDefinitionNote: Note): DVMCard { - val noteEvent = - appDefinitionNote.event as? AppDefinitionEvent ?: return DVMCard( - name = "", - description = "", - cover = null, - amount = "", - personalized = false, - ) - - val card by - appDefinitionNote - .live() - .metadata - .map { - convertAppMetadataToCard((it.note.event as? AppDefinitionEvent)?.appMetaData()) - }.distinctUntilChanged() - .observeAsState( - convertAppMetadataToCard(noteEvent.appMetaData()), - ) - +fun observeAppDefinition( + appDefinitionNote: Note, + accountViewModel: AccountViewModel, +): DVMCard { + val card by observeNoteAndMap(appDefinitionNote, accountViewModel) { + convertAppMetadataToCard((it.event as? AppDefinitionEvent)?.appMetaData()) + } return card } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt index d7b3214b50..d310456396 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt @@ -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,23 +21,19 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow -import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.ui.components.LoadNote -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton -import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.ui.note.elements.BannerImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.Size34dp +import com.vitorpamplona.amethyst.ui.theme.SimpleImage35Modifier @Composable fun DvmTopBar( @@ -49,25 +45,37 @@ fun DvmTopBar( title = { LoadNote(baseNoteHex = appDefinitionId, accountViewModel = accountViewModel) { appDefinitionNote -> if (appDefinitionNote != null) { - val card = observeAppDefinition(appDefinitionNote) + val card = observeAppDefinition(appDefinitionNote, accountViewModel) card.cover?.let { - AsyncImage( - model = it, - contentDescription = null, + MyAsyncImage( + imageUrl = it, + contentDescription = card.name, contentScale = ContentScale.Crop, - modifier = - Modifier - .size(Size34dp) - .clip(shape = CircleShape), + mainImageModifier = Modifier, + loadedImageModifier = SimpleImage35Modifier, + accountViewModel = accountViewModel, + onLoadingBackground = { + appDefinitionNote.author?.let { author -> + BannerImage(author, SimpleImage35Modifier, accountViewModel) + } + }, + onError = { + appDefinitionNote.author?.let { author -> + BannerImage(author, SimpleImage35Modifier, accountViewModel) + } + }, ) - } ?: run { NoteAuthorPicture(baseNote = appDefinitionNote, size = Size34dp, accountViewModel = accountViewModel) } + } ?: run { + appDefinitionNote.author?.let { author -> + BannerImage(author, SimpleImage35Modifier, accountViewModel) + } + } Spacer(modifier = DoubleHorzSpacer) Text( text = card.name, - fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryFeedViewModel.kt new file mode 100644 index 0000000000..0b3e7cd867 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryFeedViewModel.kt @@ -0,0 +1,43 @@ +/** + * 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.ui.screen.loggedIn.dvms.dal + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.FeedViewModel + +@Stable +class NIP90ContentDiscoveryFeedViewModel( + val account: Account, + dvmKey: String, + requestId: String, +) : FeedViewModel(NIP90ContentDiscoveryResponseFilter(account, dvmKey, requestId)) { + class Factory( + val account: Account, + val dvmKey: String, + val requestId: String, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = NIP90ContentDiscoveryFeedViewModel(account, dvmKey, requestId) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/NIP90ContentDiscoveryResponseFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryResponseFilter.kt similarity index 90% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/NIP90ContentDiscoveryResponseFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryResponseFilter.kt index e489bc323b..34d52608ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/NIP90ContentDiscoveryResponseFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryResponseFilter.kt @@ -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,15 +18,17 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.observables.CreatedAtComparator +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.nip01Core.tags.events.isTaggedEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent open class NIP90ContentDiscoveryResponseFilter( @@ -71,10 +73,8 @@ open class NIP90ContentDiscoveryResponseFilter( fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( - account.userProfile().pubkeyHex, - account.settings.defaultDiscoveryFollowList.value, account.liveDiscoveryFollowLists.value, - account.flowHiddenUsers.value, + account.hiddenUsers.flow.value, ) protected open fun innerApplyFilter(collection: Collection): Set { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt new file mode 100644 index 0000000000..6baf3fdf32 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash + +import android.net.Uri +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import com.vitorpamplona.amethyst.ui.note.nip22Comments.CommentPostViewModel +import com.vitorpamplona.amethyst.ui.note.nip22Comments.GenericCommentPostScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +fun GeoHashPostScreen( + geohash: String? = null, + message: String? = null, + attachment: Uri? = null, + reply: Note? = null, + quote: Note? = null, + draft: Note? = null, + accountViewModel: AccountViewModel, + nav: Nav, +) { + val postViewModel: CommentPostViewModel = viewModel() + postViewModel.init(accountViewModel) + + val context = LocalContext.current + + LaunchedEffect(Unit) { + geohash?.let { + postViewModel.newPostFor(GeohashId(it)) + } + reply?.let { + postViewModel.reply(it) + } + draft?.let { + postViewModel.editFromDraft(it) + } + quote?.let { + postViewModel.quote(it) + } + message?.ifBlank { null }?.let { + postViewModel.updateMessage(TextFieldValue(it)) + } + attachment?.let { + withContext(Dispatchers.IO) { + val mediaType = context.contentResolver.getType(it) + postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) + } + } + } + + GenericCommentPostScreen(postViewModel, accountViewModel, nav) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashScreen.kt index 2bcd6fa01f..443f99376e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashScreen.kt @@ -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,122 +20,86 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement +import android.annotation.SuppressLint import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.text.font.FontWeight -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.service.NostrGeohashDataSource -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingGeohash +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName -import com.vitorpamplona.amethyst.ui.screen.NostrGeoHashFeedViewModel import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.dal.GeoHashFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.FollowButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.UnfollowButton -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.StdPadding @Composable fun GeoHashScreen( - tag: String?, + tag: Route.Geohash, accountViewModel: AccountViewModel, nav: INav, ) { - if (tag == null) return + if (tag.geohash.isEmpty()) return PrepareViewModelsGeoHashScreen(tag, accountViewModel, nav) } +@SuppressLint("StateFlowValueCalledInComposition") @Composable fun PrepareViewModelsGeoHashScreen( - tag: String, + tag: Route.Geohash, accountViewModel: AccountViewModel, nav: INav, ) { - val followsFeedViewModel: NostrGeoHashFeedViewModel = + val geohashViewModel: GeoHashFeedViewModel = viewModel( - key = tag + "GeoHashFeedViewModel", + key = tag.geohash + "GeoHashFeedViewModel", factory = - NostrGeoHashFeedViewModel.Factory( - tag, + GeoHashFeedViewModel.Factory( + tag.geohash, + accountViewModel.account.followOutboxesOrProxy.flow.value, accountViewModel.account, ), ) - GeoHashScreen(tag, followsFeedViewModel, accountViewModel, nav) + GeoHashScreen(tag, geohashViewModel, accountViewModel, nav) } @Composable fun GeoHashScreen( - tag: String, - feedViewModel: NostrGeoHashFeedViewModel, + tag: Route.Geohash, + feedViewModel: GeoHashFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { - val lifeCycleOwner = LocalLifecycleOwner.current - - NostrGeohashDataSource.loadHashtag(tag) - - DisposableEffect(tag) { - NostrGeohashDataSource.start() - feedViewModel.invalidateData() - onDispose { - NostrGeohashDataSource.loadHashtag(null) - NostrGeohashDataSource.stop() - } - } - - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Hashtag Start") - NostrGeohashDataSource.loadHashtag(tag) - NostrGeohashDataSource.start() - feedViewModel.invalidateData() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Hashtag Stop") - NostrGeohashDataSource.loadHashtag(null) - NostrGeohashDataSource.stop() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } + WatchLifecycleAndUpdateModel(feedViewModel) + GeoHashFilterAssemblerSubscription(tag, accountViewModel) DisappearingScaffold( isInvertedLayout = false, topBar = { TopBarExtensibleWithBackButton( title = { - DislayGeoTagHeader(tag, Modifier.weight(1f)) - GeoHashActionOptions(tag, accountViewModel) + DisplayGeoTagHeader(tag.geohash, Modifier.weight(1f)) + GeoHashActionOptions(tag.geohash, accountViewModel) }, popBack = nav::popBack, ) }, + floatingButton = { + NewGeoPostButton(tag.geohash, accountViewModel, nav) + }, accountViewModel = accountViewModel, ) { Column(Modifier.padding(it)) { @@ -150,34 +114,7 @@ fun GeoHashScreen( } @Composable -fun GeoHashHeader( - tag: String, - modifier: Modifier = StdPadding, - account: AccountViewModel, - onClick: () -> Unit = {}, -) { - Column( - Modifier.fillMaxWidth().clickable { onClick() }, - ) { - Column(modifier = modifier) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - DislayGeoTagHeader(tag, remember { Modifier.weight(1f) }) - - GeoHashActionOptions(tag, account) - } - } - - HorizontalDivider( - thickness = DividerThickness, - ) - } -} - -@Composable -fun DislayGeoTagHeader( +fun DisplayGeoTagHeader( geohash: String, modifier: Modifier, ) { @@ -195,15 +132,7 @@ fun GeoHashActionOptions( tag: String, accountViewModel: AccountViewModel, ) { - val userState by accountViewModel - .userProfile() - .live() - .follows - .observeAsState() - val isFollowingTag by - remember(userState, tag) { - derivedStateOf { userState?.user?.isFollowingGeohash(tag) ?: false } - } + val isFollowingTag by observeUserIsFollowingGeohash(accountViewModel.userProfile(), tag, accountViewModel) if (isFollowingTag) { UnfollowButton { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/NewGeoNoteButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/NewGeoNoteButton.kt new file mode 100644 index 0000000000..9191b0839f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/NewGeoNoteButton.kt @@ -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.ui.screen.loggedIn.geohash + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.painterRes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size26Modifier +import com.vitorpamplona.amethyst.ui.theme.Size55Modifier + +@Composable +fun NewGeoPostButton( + tag: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + FloatingActionButton( + onClick = { + nav.nav(Route.GeoPost(tag)) + }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + painter = painterRes(R.drawable.ic_compose, 1), + contentDescription = stringRes(id = R.string.new_community_note), + modifier = Size26Modifier, + tint = Color.White, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt similarity index 65% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt index 6061f6b34d..a0f556e32b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt @@ -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,29 +18,37 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHash import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId class GeoHashFeedFilter( val tag: String, + val relays: Set, val account: Account, + val cache: LocalCache, ) : AdditiveFeedFilter() { override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + tag override fun feed(): List { val notes = - LocalCache.notes.filterIntoSet { _, it -> + cache.notes.filterIntoSet { _, it -> acceptableEvent(it, tag) } @@ -55,18 +63,29 @@ class GeoHashFeedFilter( it: Note, geoTag: String, ): Boolean = - ( - it.event is TextNoteEvent || - it.event is LongTextNoteEvent || - it.event is WikiNoteEvent || - it.event is ChannelMessageEvent || - it.event is PrivateDmEvent || - it.event is PollNoteEvent || - it.event is AudioHeaderEvent - ) && - it.event?.isTaggedGeoHash(geoTag) == true && - !it.isHiddenFor(account.flowHiddenUsers.value) && + (acceptableViaHashtag(it.event, geoTag) || acceptableViaScope(it.event, geoTag)) && + !it.isHiddenFor(account.hiddenUsers.flow.value) && account.isAcceptable(it) + fun acceptableViaHashtag( + event: Event?, + geohash: String, + ): Boolean = + ( + event is TextNoteEvent || + event is LongTextNoteEvent || + event is WikiNoteEvent || + event is ChannelMessageEvent || + event is PrivateDmEvent || + event is PollNoteEvent || + event is AudioHeaderEvent + ) && + event.isTaggedGeoHash(geohash) + + fun acceptableViaScope( + event: Event?, + geohash: String, + ): Boolean = event is CommentEvent && event.isTaggedScope(geohash, GeohashId::match) + override fun sort(collection: Set): List = collection.sortedWith(DefaultFeedOrder) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedViewModel.kt new file mode 100644 index 0000000000..bd754f7a24 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedViewModel.kt @@ -0,0 +1,48 @@ +/** + * 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.ui.screen.loggedIn.geohash.dal + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.screen.FeedViewModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +@Stable +class GeoHashFeedViewModel( + val geohash: String, + val relays: Set, + val account: Account, +) : FeedViewModel( + GeoHashFeedFilter(geohash, relays, account, LocalCache), + ) { + @Suppress("UNCHECKED_CAST") + class Factory( + val geohash: String, + val relays: Set, + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = GeoHashFeedViewModel(geohash, relays, account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt new file mode 100644 index 0000000000..6903974de0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt @@ -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.ui.screen.loggedIn.geohash.datasource + +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.CommentKinds +import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent + +val PostsByGeohashKinds = + listOf( + TextNoteEvent.KIND, + ChannelMessageEvent.KIND, + LongTextNoteEvent.KIND, + PollNoteEvent.KIND, + ClassifiedsEvent.KIND, + HighlightEvent.KIND, + AudioTrackEvent.KIND, + AudioHeaderEvent.KIND, + WikiNoteEvent.KIND, + CommentEvent.KIND, + ) + +fun filterPostsByGeohash( + geohash: String, + relays: Set, + since: SincePerRelayMap?, +): List { + val geohashesToFollowMap = mapOf("g" to listOf(geohash)) + val geohashesScoreMap = mapOf("I" to listOf(GeohashId.toScope(geohash))) + + return relays.flatMap { relay -> + val since = since?.get(relay)?.time + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = geohashesToFollowMap, + kinds = PostsByGeohashKinds, + limit = 100, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = geohashesScoreMap, + kinds = CommentKinds, + limit = 100, + since = since, + ), + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFeedFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFeedFilterSubAssembler.kt new file mode 100644 index 0000000000..1418f06432 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFeedFilterSubAssembler.kt @@ -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.ui.screen.loggedIn.geohash.datasource + +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +class GeoHashFeedFilterSubAssembler( + client: NostrClient, + allKeys: () -> Set, +) : PerUniqueIdEoseManager(client, allKeys) { + override fun updateFilter( + key: GeohashQueryState, + since: SincePerRelayMap?, + ): List? = filterPostsByGeohash(key.geohash, key.relays, since) + + /** + * Only one key per hashtag. + */ + override fun id(key: GeohashQueryState) = key.lowercaseGeohash +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssembler.kt new file mode 100644 index 0000000000..a1d84cf27a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssembler.kt @@ -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.ui.screen.loggedIn.geohash.datasource + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +// This allows multiple screen to be listening to tags, even the same tag +class GeohashQueryState( + val geohash: String, + val relays: Set, +) { + val lowercaseGeohash = geohash.lowercase() +} + +/** + * Creates a filter for multiple geohashes at the same time. + */ +@Stable +class GeoHashFilterAssembler( + client: NostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + GeoHashFeedFilterSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } + + override fun printStats() = group.forEach { it.printStats() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..49bbf8ef1a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssemblerSubscription.kt @@ -0,0 +1,44 @@ +/** + * 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.ui.screen.loggedIn.geohash.datasource + +import android.annotation.SuppressLint +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@SuppressLint("StateFlowValueCalledInComposition") +@Composable +fun GeoHashFilterAssemblerSubscription( + tag: Route.Geohash, + accountViewModel: AccountViewModel, +) { + // different screens get different states + // even if they are tracking the same tag. + val state = + remember(tag) { + GeohashQueryState(tag.geohash, accountViewModel.account.followOutboxesOrProxy.flow.value) + } + + KeyDataSourceSubscription(state, accountViewModel.dataSources().geohashes) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt new file mode 100644 index 0000000000..59d4e8f8f9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag + +import android.net.Uri +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import com.vitorpamplona.amethyst.ui.note.nip22Comments.CommentPostViewModel +import com.vitorpamplona.amethyst.ui.note.nip22Comments.GenericCommentPostScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +fun HashtagPostScreen( + hashtag: String? = null, + message: String? = null, + attachment: Uri? = null, + reply: Note? = null, + quote: Note? = null, + draft: Note? = null, + accountViewModel: AccountViewModel, + nav: Nav, +) { + val postViewModel: CommentPostViewModel = viewModel() + postViewModel.init(accountViewModel) + + val context = LocalContext.current + + LaunchedEffect(Unit) { + hashtag?.let { + postViewModel.newPostFor(HashtagId(it)) + } + reply?.let { + postViewModel.reply(it) + } + draft?.let { + postViewModel.editFromDraft(it) + } + quote?.let { + postViewModel.quote(it) + } + message?.ifBlank { null }?.let { + postViewModel.updateMessage(TextFieldValue(it)) + } + attachment?.let { + withContext(Dispatchers.IO) { + val mediaType = context.contentResolver.getType(it) + postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) + } + } + } + + GenericCommentPostScreen(postViewModel, accountViewModel, nav) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagScreen.kt index 7d60fb079f..d8fbb9dc2c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagScreen.kt @@ -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,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag +import android.annotation.SuppressLint import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -28,112 +29,82 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.text.font.FontWeight -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.service.NostrHashtagDataSource -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton -import com.vitorpamplona.amethyst.ui.screen.NostrHashtagFeedViewModel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingHashtag +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.dal.HashtagFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource.HashtagFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.FollowButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.UnfollowButton import com.vitorpamplona.amethyst.ui.theme.StdPadding @Composable fun HashtagScreen( - tag: String?, + tag: Route.Hashtag, accountViewModel: AccountViewModel, nav: INav, ) { - if (tag == null) return + if (tag.hashtag.isEmpty()) return PrepareViewModelsHashtagScreen(tag, accountViewModel, nav) } +@SuppressLint("StateFlowValueCalledInComposition") @Composable fun PrepareViewModelsHashtagScreen( - tag: String, + tag: Route.Hashtag, accountViewModel: AccountViewModel, nav: INav, ) { - val followsFeedViewModel: NostrHashtagFeedViewModel = + val hashtagFeedViewModel: HashtagFeedViewModel = viewModel( - key = tag + "HashtagFeedViewModel", + key = tag.hashtag + "HashtagFeedViewModel", factory = - NostrHashtagFeedViewModel.Factory( - tag, + HashtagFeedViewModel.Factory( + tag.hashtag, + accountViewModel.account.followOutboxesOrProxy.flow.value, accountViewModel.account, ), ) - HashtagScreen(tag, followsFeedViewModel, accountViewModel, nav) + HashtagScreen(tag, hashtagFeedViewModel, accountViewModel, nav) } @Composable fun HashtagScreen( - tag: String, - feedViewModel: NostrHashtagFeedViewModel, + tag: Route.Hashtag, + feedViewModel: HashtagFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { - val lifeCycleOwner = LocalLifecycleOwner.current - - NostrHashtagDataSource.loadHashtag(tag) - - DisposableEffect(tag) { - NostrHashtagDataSource.start() - feedViewModel.invalidateData() - - onDispose { - NostrHashtagDataSource.loadHashtag(null) - NostrHashtagDataSource.stop() - } - } - - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Hashtag Start") - NostrHashtagDataSource.loadHashtag(tag) - NostrHashtagDataSource.start() - feedViewModel.invalidateData() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Hashtag Stop") - NostrHashtagDataSource.loadHashtag(null) - NostrHashtagDataSource.stop() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } + WatchLifecycleAndUpdateModel(feedViewModel) + HashtagFilterAssemblerSubscription(tag, accountViewModel) DisappearingScaffold( isInvertedLayout = false, topBar = { TopBarExtensibleWithBackButton( title = { - Text("#$tag", modifier = Modifier.weight(1f)) - HashtagActionOptions(tag, accountViewModel) + Text("#${tag.hashtag}", modifier = Modifier.weight(1f)) + HashtagActionOptions(tag.hashtag, accountViewModel) }, popBack = nav::popBack, ) }, + floatingButton = { + NewHashtagPostButton(tag.hashtag, accountViewModel, nav) + }, accountViewModel = accountViewModel, ) { Column(Modifier.padding(it)) { @@ -174,15 +145,7 @@ fun HashtagActionOptions( tag: String, accountViewModel: AccountViewModel, ) { - val userState by accountViewModel - .userProfile() - .live() - .follows - .observeAsState() - val isFollowingTag by - remember(userState, tag) { - derivedStateOf { userState?.user?.isFollowingHashtag(tag) ?: false } - } + val isFollowingTag by observeUserIsFollowingHashtag(accountViewModel.userProfile(), tag, accountViewModel) if (isFollowingTag) { UnfollowButton { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/NewHashtagNoteButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/NewHashtagNoteButton.kt new file mode 100644 index 0000000000..287971b8a6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/NewHashtagNoteButton.kt @@ -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.ui.screen.loggedIn.hashtag + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.painterRes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size26Modifier +import com.vitorpamplona.amethyst.ui.theme.Size55Modifier + +@Composable +fun NewHashtagPostButton( + tag: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + FloatingActionButton( + onClick = { + nav.nav(Route.HashtagPost(tag)) + }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + painter = painterRes(R.drawable.ic_compose, 3), + contentDescription = stringRes(id = R.string.new_community_note), + modifier = Size26Modifier, + tint = Color.White, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt similarity index 65% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt index 1881067b1d..be5a8f4834 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt @@ -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,31 +18,39 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId class HashtagFeedFilter( val tag: String, + val relays: Set, val account: Account, + val cache: LocalCache, ) : AdditiveFeedFilter() { override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + tag override fun feed(): List { val notes = - LocalCache.notes.filterIntoSet { _, it -> + cache.notes.filterIntoSet { _, it -> acceptableEvent(it, tag) } @@ -57,20 +65,31 @@ class HashtagFeedFilter( it: Note, hashTag: String, ): Boolean = - ( - it.event is TextNoteEvent || - it.event is RepostEvent || - it.event is GenericRepostEvent || - it.event is LongTextNoteEvent || - it.event is WikiNoteEvent || - it.event is ChannelMessageEvent || - it.event is PrivateDmEvent || - it.event is PollNoteEvent || - it.event is AudioHeaderEvent - ) && - it.event?.isTaggedHash(hashTag) == true && - !it.isHiddenFor(account.flowHiddenUsers.value) && + (acceptableViaHashtag(it.event, hashTag) || acceptableViaScope(it.event, hashTag)) && + !it.isHiddenFor(account.hiddenUsers.flow.value) && account.isAcceptable(it) + fun acceptableViaHashtag( + event: Event?, + hashTag: String, + ): Boolean = + ( + event is TextNoteEvent || + event is RepostEvent || + event is GenericRepostEvent || + event is LongTextNoteEvent || + event is WikiNoteEvent || + event is ChannelMessageEvent || + event is PrivateDmEvent || + event is PollNoteEvent || + event is AudioHeaderEvent + ) && + event.isTaggedHash(hashTag) == true + + fun acceptableViaScope( + event: Event?, + hashTag: String, + ): Boolean = event is CommentEvent && event.isTaggedScope(hashTag, HashtagId::match) + override fun sort(collection: Set): List = collection.sortedWith(DefaultFeedOrder) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedViewModel.kt new file mode 100644 index 0000000000..b1b4879417 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedViewModel.kt @@ -0,0 +1,48 @@ +/** + * 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.ui.screen.loggedIn.hashtag.dal + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.screen.FeedViewModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +@Stable +class HashtagFeedViewModel( + val hashtag: String, + val relays: Set, + val account: Account, +) : FeedViewModel( + HashtagFeedFilter(hashtag, relays, account, LocalCache), + ) { + @Suppress("UNCHECKED_CAST") + class Factory( + val hashtag: String, + val relays: Set, + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = HashtagFeedViewModel(hashtag, relays, account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt new file mode 100644 index 0000000000..5b0c37659b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt @@ -0,0 +1,106 @@ +/** + * 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.ui.screen.loggedIn.hashtag.datasource + +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.CommentKinds +import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent +import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent + +val PostsByHashtagsKinds = + listOf( + TextNoteEvent.KIND, + ChannelMessageEvent.KIND, + LongTextNoteEvent.KIND, + PollNoteEvent.KIND, + LiveActivitiesChatMessageEvent.KIND, + ClassifiedsEvent.KIND, + HighlightEvent.KIND, + WikiNoteEvent.KIND, + CommentEvent.KIND, + ) + +val PostsByHashtagKinds2 = + listOf( + InteractiveStorySceneEvent.KIND, + AudioTrackEvent.KIND, + AudioHeaderEvent.KIND, + ) + +fun filterPostsByHashtags( + hashtag: String, + relays: Set, + since: SincePerRelayMap?, +): List { + val hashtagsToFollowMap = mapOf("t" to hashtagAlts(hashtag).sorted()) + val hashtagScoreMap = mapOf("I" to listOf(HashtagId.toScope(hashtag))) + + return relays.flatMap { relay -> + val since = since?.get(relay)?.time + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = hashtagsToFollowMap, + kinds = PostsByHashtagsKinds, + limit = 400, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = hashtagsToFollowMap, + kinds = PostsByHashtagKinds2, + limit = 100, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = hashtagScoreMap, + kinds = CommentKinds, + limit = 200, + since = since, + ), + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFeedFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFeedFilterSubAssembler.kt new file mode 100644 index 0000000000..b59790f2c0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFeedFilterSubAssembler.kt @@ -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.ui.screen.loggedIn.hashtag.datasource + +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +class HashtagFeedFilterSubAssembler( + client: NostrClient, + allKeys: () -> Set, +) : PerUniqueIdEoseManager(client, allKeys) { + override fun updateFilter( + key: HashtagQueryState, + since: SincePerRelayMap?, + ): List = filterPostsByHashtags(key.hashtag, key.relays, since) + + /** + * Only one key per hashtag. + */ + override fun id(key: HashtagQueryState) = key.lowercaseHashtag +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssembler.kt new file mode 100644 index 0000000000..ada311d148 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssembler.kt @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource + +import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +// This allows multiple screen to be listening to tags, even the same tag +class HashtagQueryState( + val hashtag: String, + val relays: Set, +) { + val lowercaseHashtag = hashtag.lowercase() +} + +class HashtagFilterAssembler( + client: NostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + HashtagFeedFilterSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } + + override fun printStats() = group.forEach { it.printStats() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/WatchIsHiddenUser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssemblerSubscription.kt similarity index 66% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/WatchIsHiddenUser.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssemblerSubscription.kt index c66cf97679..1bf1a83872 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/WatchIsHiddenUser.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssemblerSubscription.kt @@ -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,26 +18,25 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps +package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.lifecycle.map -import com.vitorpamplona.amethyst.model.User +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable -fun WatchIsHiddenUser( - baseUser: User, +fun HashtagFilterAssemblerSubscription( + tag: Route.Hashtag, accountViewModel: AccountViewModel, - content: @Composable (Boolean) -> Unit, ) { - val isHidden by - accountViewModel.account.liveHiddenUsers - .map { - it.hiddenUsers.contains(baseUser.pubkeyHex) || it.spammers.contains(baseUser.pubkeyHex) - }.observeAsState(accountViewModel.account.isHidden(baseUser)) + // different screens get different states + // even if they are tracking the same tag. + val state = + remember(tag) { + HashtagQueryState(tag.hashtag, accountViewModel.account.followOutboxesOrProxy.flow.value) + } - content(isHidden) + KeyDataSourceSubscription(state, accountViewModel.dataSources().hashtags) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 65f2b44461..dab1e76605 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -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,57 +22,77 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.tooling.preview.Preview -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.AROUND_ME -import com.vitorpamplona.amethyst.service.NostrHomeDataSource import com.vitorpamplona.amethyst.service.OnlineChecker +import com.vitorpamplona.amethyst.service.OnlineChecker.isOnline +import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled +import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState +import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedState import com.vitorpamplona.amethyst.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.ui.feeds.FeedState import com.vitorpamplona.amethyst.ui.feeds.PagerStateKeys import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState -import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.NewGeoPostButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.live.RenderEphemeralBubble import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.HorzPadding +import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.TabRowHeight import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch +import kotlin.collections.forEachIndexed @Composable fun HomeScreen( @@ -80,6 +100,7 @@ fun HomeScreen( nav: INav, ) { HomeScreen( + liveFeedState = accountViewModel.feedStates.homeLive, newThreadsFeedState = accountViewModel.feedStates.homeNewThreads, repliesFeedState = accountViewModel.feedStates.homeReplies, accountViewModel = accountViewModel, @@ -90,6 +111,7 @@ fun HomeScreen( @OptIn(ExperimentalFoundationApi::class) @Composable fun HomeScreen( + liveFeedState: ChannelFeedContentState, newThreadsFeedState: FeedContentState, repliesFeedState: FeedContentState, accountViewModel: AccountViewModel, @@ -97,9 +119,13 @@ fun HomeScreen( ) { WatchAccountForHomeScreen(newThreadsFeedState, repliesFeedState, accountViewModel) - WatchLifeCycleChanges(accountViewModel) + WatchLifecycleAndUpdateModel(liveFeedState) + WatchLifecycleAndUpdateModel(newThreadsFeedState) + WatchLifecycleAndUpdateModel(repliesFeedState) - AssembleHomeTabs(newThreadsFeedState, repliesFeedState) { pagerState, tabItems -> + HomeFilterAssemblerSubscription(accountViewModel) + + AssembleHomeTabs(newThreadsFeedState, repliesFeedState, liveFeedState) { pagerState, tabItems -> HomePages(pagerState, tabItems, accountViewModel, nav) } } @@ -109,6 +135,7 @@ fun HomeScreen( private fun AssembleHomeTabs( newThreadsFeedState: FeedContentState, repliesFeedState: FeedContentState, + liveFeedState: ChannelFeedContentState, inner: @Composable (PagerState, ImmutableList) -> Unit, ) { val pagerState = rememberForeverPagerState(key = PagerStateKeys.HOME_SCREEN) { 2 } @@ -122,12 +149,14 @@ private fun AssembleHomeTabs( feedState = newThreadsFeedState, routeForLastRead = "HomeFollows", scrollStateKey = ScrollStateKeys.HOME_FOLLOWS, + liveSection = liveFeedState, ), TabItem( resource = R.string.conversations, feedState = repliesFeedState, routeForLastRead = "HomeFollowsReplies", scrollStateKey = ScrollStateKeys.HOME_REPLIES, + liveSection = liveFeedState, ), ).toImmutableList(), ) @@ -136,23 +165,6 @@ private fun AssembleHomeTabs( inner(pagerState, tabs) } -@Composable -private fun WatchLifeCycleChanges(accountViewModel: AccountViewModel) { - val lifeCycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - NostrHomeDataSource.account = accountViewModel.account - NostrHomeDataSource.invalidateFilters() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } -} - @Composable private fun HomePages( pagerState: PagerState, @@ -192,11 +204,7 @@ private fun HomePages( } }, floatingButton = { - val list = - accountViewModel.account.settings.defaultHomeFollowList - .collectAsStateWithLifecycle() - - NewNoteButton(nav, list.value == AROUND_ME) + HomeScreenFloatingButton(accountViewModel, nav) }, accountViewModel = accountViewModel, ) { @@ -209,6 +217,7 @@ private fun HomePages( feedState = tabs[page].feedState, routeForLastRead = tabs[page].routeForLastRead, scrollStateKey = tabs[page].scrollStateKey, + liveSection = tabs[page].liveSection, accountViewModel = accountViewModel, nav = nav, ) @@ -216,12 +225,36 @@ private fun HomePages( } } +@Composable +fun HomeScreenFloatingButton( + accountViewModel: AccountViewModel, + nav: INav, +) { + val list = + accountViewModel.account.settings.defaultHomeFollowList + .collectAsStateWithLifecycle() + + if (list.value == AROUND_ME) { + val location by Amethyst.instance.locationManager.geohashStateFlow + .collectAsStateWithLifecycle() + + when (val myLocation = location) { + is LocationState.LocationResult.Success -> NewGeoPostButton(myLocation.geoHash.toString(), accountViewModel, nav) + is LocationState.LocationResult.LackPermission -> { } + is LocationState.LocationResult.Loading -> { } + } + } else { + NewNoteButton(nav) + } +} + @Composable fun HomeFeeds( feedState: FeedContentState, routeForLastRead: String?, enablePullRefresh: Boolean = true, scrollStateKey: String? = null, + liveSection: ChannelFeedContentState? = null, accountViewModel: AccountViewModel, nav: INav, ) { @@ -233,12 +266,91 @@ fun HomeFeeds( listState = listState, nav = nav, routeForLastRead = routeForLastRead, + onLoaded = { FeedLoaded(it, listState, routeForLastRead, liveSection, accountViewModel, nav) }, onEmpty = { HomeFeedEmpty(feedState::invalidateData) }, ) } } } +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun FeedLoaded( + loaded: FeedState.Loaded, + listState: LazyListState, + routeForLastRead: String?, + liveSection: ChannelFeedContentState? = null, + accountViewModel: AccountViewModel, + nav: INav, +) { + val items by loaded.feed.collectAsStateWithLifecycle() + + LazyColumn( + contentPadding = FeedPadding, + state = listState, + ) { + if (liveSection != null) { + item { + DisplayLiveBubbles(liveSection, accountViewModel, nav) + Spacer(StdVertSpacer) + } + } + itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item -> + Row( + Modifier + .fillMaxWidth() + .animateItem(), + ) { + NoteCompose( + item, + modifier = Modifier.fillMaxWidth(), + routeForLastRead = routeForLastRead, + isBoostedNote = false, + isHiddenFeed = items.showHidden, + quotesLeft = 3, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + HorizontalDivider( + thickness = DividerThickness, + ) + } + } +} + +@Composable +fun DisplayLiveBubbles( + liveSection: ChannelFeedContentState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val feedState by liveSection.feedContent.collectAsStateWithLifecycle() + + when (val state = feedState) { + is ChannelFeedState.Empty -> null + is ChannelFeedState.FeedError -> null + is ChannelFeedState.Loaded -> DisplayLiveBubbles(state, accountViewModel, nav) + is ChannelFeedState.Loading -> null + } +} + +@Composable +fun DisplayLiveBubbles( + liveFeed: ChannelFeedState.Loaded, + accountViewModel: AccountViewModel, + nav: INav, +) { + val feed by liveFeed.feed.collectAsStateWithLifecycle() + + LazyRow(HorzPadding, horizontalArrangement = spacedBy(Size5dp)) { + itemsIndexed(feed.list, key = { _, item -> item.roomId.toKey() }) { _, item -> + RenderEphemeralBubble(item, accountViewModel, nav) + } + } +} + @Preview @Composable fun HomeFeedEmptyPreview() { @@ -266,17 +378,13 @@ fun CheckIfVideoIsOnline( accountViewModel: AccountViewModel, whenOnline: @Composable (Boolean) -> Unit, ) { - var online by remember { - mutableStateOf( - OnlineChecker.isOnlineCached(url), - ) - } - - LaunchedEffect(key1 = url) { - accountViewModel.checkVideoIsOnline(url) { isOnline -> - if (online != isOnline) { - online = isOnline - } + val online by produceState( + initialValue = OnlineChecker.isOnlineCached(url), + key1 = url, + ) { + val isOnline = accountViewModel.checkVideoIsOnline(url) + if (value != isOnline) { + value = isOnline } } @@ -289,17 +397,13 @@ fun CrossfadeCheckIfVideoIsOnline( accountViewModel: AccountViewModel, whenOnline: @Composable () -> Unit, ) { - var online by remember { - mutableStateOf( - OnlineChecker.isOnlineCached(url), - ) - } - - LaunchedEffect(key1 = url) { - accountViewModel.checkVideoIsOnline(url) { isOnline -> - if (online != isOnline) { - online = isOnline - } + val online by produceState( + initialValue = OnlineChecker.isOnlineCached(url), + key1 = url, + ) { + val isOnline = accountViewModel.checkVideoIsOnline(url) + if (value != isOnline) { + value = isOnline } } @@ -323,8 +427,6 @@ fun WatchAccountForHomeScreen( val homeFollowList by accountViewModel.account.liveHomeFollowLists.collectAsStateWithLifecycle() LaunchedEffect(accountViewModel, homeFollowList) { - NostrHomeDataSource.account = accountViewModel.account - NostrHomeDataSource.invalidateFilters() newThreadsFeedState.checkKeysInvalidateDataAndSendToTop() repliesFeedState.checkKeysInvalidateDataAndSendToTop() } @@ -338,4 +440,5 @@ class TabItem( val scrollStateKey: String, val forceEventKind: Int? = null, val useGridLayout: Boolean = false, + val liveSection: ChannelFeedContentState? = null, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt index f1b07a9d88..c61f41794a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt @@ -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,23 +23,28 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.ui.navigation.FollowListWithRoutes -import com.vitorpamplona.amethyst.ui.navigation.GenericMainTopBar -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner +import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar +import com.vitorpamplona.amethyst.ui.screen.FeedDefinition +import com.vitorpamplona.amethyst.ui.screen.FollowListState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes @Composable fun HomeTopBar( accountViewModel: AccountViewModel, nav: INav, ) { - GenericMainTopBar(accountViewModel, nav) { + UserDrawerSearchTopBar(accountViewModel, nav) { val list by accountViewModel.account.settings.defaultHomeFollowList .collectAsStateWithLifecycle() - FollowListWithRoutes( + FollowList( followListsModel = accountViewModel.feedStates.feedListOptions, listName = list, + accountViewModel = accountViewModel, ) { listName -> if (listName.route != null) { nav.nav(listName.route) @@ -49,3 +54,21 @@ fun HomeTopBar( } } } + +@Composable +private fun FollowList( + followListsModel: FollowListState, + listName: String, + accountViewModel: AccountViewModel, + onChange: (FeedDefinition) -> Unit, +) { + val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle() + + FeedFilterSpinner( + placeholderCode = listName, + explainer = stringRes(R.string.select_list_to_filter), + options = allLists, + onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.kind3Follow) }, + accountViewModel = accountViewModel, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewNoteButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewNoteButton.kt index 35bc30eea0..cc87c14c6f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewNoteButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewNoteButton.kt @@ -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,39 +20,34 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home -import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size26Modifier import com.vitorpamplona.amethyst.ui.theme.Size55Modifier @Composable -fun NewNoteButton( - nav: INav, - enableGeolocation: Boolean = false, -) { +fun NewNoteButton(nav: INav) { FloatingActionButton( onClick = { - nav.nav(Route.NewPost(enableGeolocation = enableGeolocation)) + nav.nav(Route.NewPost()) }, modifier = Size55Modifier, shape = CircleShape, containerColor = MaterialTheme.colorScheme.primary, ) { Icon( - painter = painterResource(R.drawable.ic_compose), + painter = painterRes(R.drawable.ic_compose, 4), contentDescription = stringRes(R.string.new_post), - modifier = Modifier.size(26.dp), + modifier = Size26Modifier, tint = Color.White, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt new file mode 100644 index 0000000000..ec309dcfd4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -0,0 +1,490 @@ +/** + * 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.ui.screen.loggedIn.home + +import android.content.Intent +import android.net.Uri +import android.os.Parcelable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.core.util.Consumer +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton +import com.vitorpamplona.amethyst.ui.components.getActivity +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar +import com.vitorpamplona.amethyst.ui.note.BaseUserPicture +import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer +import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList +import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton +import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest +import com.vitorpamplona.amethyst.ui.note.creators.location.AddGeoHashButton +import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash +import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField +import com.vitorpamplona.amethyst.ui.note.creators.notify.Notifying +import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews +import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton +import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest +import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.zappolls.PollField +import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.AddZapraiserButton +import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.ZapRaiserRequest +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapTo +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapToButton +import com.vitorpamplona.amethyst.ui.painterRes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class) +@Composable +fun ShortNotePostScreen( + message: String? = null, + attachment: Uri? = null, + baseReplyTo: Note? = null, + quote: Note? = null, + fork: Note? = null, + version: Note? = null, + draft: Note? = null, + accountViewModel: AccountViewModel, + nav: Nav, +) { + val postViewModel: ShortNotePostViewModel = viewModel() + postViewModel.init(accountViewModel) + + val context = LocalContext.current + val activity = context.getActivity() + + LaunchedEffect(Unit) { + launch(Dispatchers.IO) { + postViewModel.load(baseReplyTo, quote, fork, version, draft) + message?.ifBlank { null }?.let { + postViewModel.updateMessage(TextFieldValue(it)) + } + attachment?.let { + val mediaType = context.contentResolver.getType(it) + postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) + } + } + } + + DisposableEffect(nav, activity) { + // Microsoft's swift key sends Gifs as new actions + val consumer = + Consumer { intent -> + if (intent.action == Intent.ACTION_SEND) { + intent.getStringExtra(Intent.EXTRA_TEXT)?.ifBlank { null }?.let { + postViewModel.addToMessage(it) + } + + (intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri)?.let { + val mediaType = context.contentResolver.getType(it) + postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) + } + } + } + + activity.addOnNewIntentListener(consumer) + onDispose { activity.removeOnNewIntentListener(consumer) } + } + + NewPostScreenInner(postViewModel, accountViewModel, nav) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun NewPostScreenInner( + postViewModel: ShortNotePostViewModel, + accountViewModel: AccountViewModel, + nav: Nav, +) { + WatchAndLoadMyEmojiList(accountViewModel) + + Scaffold( + topBar = { + PostingTopBar( + isActive = postViewModel::canPost, + onPost = { + // uses the accountViewModel scope to avoid cancelling this + // function when the postViewModel is released + accountViewModel.runIOCatching { + postViewModel.sendPostSync() + delay(100) + nav.popBack() + } + }, + onCancel = { + // uses the accountViewModel scope to avoid cancelling this + // function when the postViewModel is released + accountViewModel.runIOCatching { + postViewModel.sendDraftSync() + nav.popBack() + postViewModel.cancel() + } + }, + ) + }, + ) { pad -> + Surface( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .imePadding(), + ) { + NewPostScreenBody(postViewModel, accountViewModel, nav) + } + } +} + +@Composable +private fun NewPostScreenBody( + postViewModel: ShortNotePostViewModel, + accountViewModel: AccountViewModel, + nav: Nav, +) { + val scrollState = rememberScrollState() + Column( + modifier = + Modifier.fillMaxSize(), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding( + start = Size10dp, + end = Size10dp, + ).weight(1f), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(scrollState), + ) { + postViewModel.originalNote?.let { + Row { + NoteCompose( + baseNote = it, + modifier = MaterialTheme.colorScheme.replyModifier, + isQuotedNote = true, + unPackReply = false, + makeItShort = true, + quotesLeft = 1, + accountViewModel = accountViewModel, + nav = nav, + ) + Spacer(modifier = StdVertSpacer) + } + } + + Row { + Notifying(postViewModel.pTags?.toImmutableList(), accountViewModel) { + postViewModel.removeFromReplyList(it) + } + } + + Row( + modifier = Modifier.padding(vertical = Size10dp), + ) { + BaseUserPicture( + accountViewModel.userProfile(), + Size35dp, + accountViewModel = accountViewModel, + ) + MessageField( + R.string.what_s_on_your_mind, + postViewModel, + ) + } + + if (postViewModel.wantsPoll) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + PollField(postViewModel) + } + } + + DisplayPreviews(postViewModel.urlPreviews, accountViewModel, nav) + + if (postViewModel.wantsToMarkAsSensitive) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + ContentSensitivityExplainer() + } + } + + if (postViewModel.wantsToAddGeoHash) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + LocationAsHash(postViewModel) { + SettingsRow( + R.string.geohash_exclusive, + R.string.geohash_exclusive_explainer, + ) { + Switch(postViewModel.wantsExclusiveGeoPost, onCheckedChange = { postViewModel.wantsExclusiveGeoPost = it }) + } + } + } + } + + if (postViewModel.wantsForwardZapTo) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(top = Size5dp, bottom = Size5dp, start = Size10dp), + ) { + ForwardZapTo(postViewModel, accountViewModel) + } + } + + postViewModel.multiOrchestrator?.let { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + val context = LocalContext.current + ImageVideoDescription( + it, + accountViewModel.account.settings.defaultFileServer, + onAdd = { alt, server, sensitiveContent, mediaQuality -> + postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context) + if (server.type != ServerType.NIP95) { + accountViewModel.account.settings.changeDefaultFileServer(server) + } + }, + onDelete = postViewModel::deleteMediaToUpload, + onCancel = { postViewModel.multiOrchestrator = null }, + accountViewModel = accountViewModel, + ) + } + } + + if (postViewModel.wantsInvoice) { + postViewModel.lnAddress()?.let { lud16 -> + InvoiceRequest( + lud16, + accountViewModel.account.userProfile(), + accountViewModel, + stringRes(id = R.string.lightning_invoice), + stringRes(id = R.string.lightning_create_and_add_invoice), + onNewInvoice = { + postViewModel.insertAtCursor(it) + postViewModel.wantsInvoice = false + }, + onError = { title, message -> accountViewModel.toastManager.toast(title, message) }, + ) + } + } + + if (postViewModel.wantsSecretEmoji) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + Column(Modifier.fillMaxWidth()) { + SecretEmojiRequest { + postViewModel.insertAtCursor(it) + postViewModel.wantsSecretEmoji = false + } + } + } + } + + if (postViewModel.wantsZapRaiser && postViewModel.hasLnAddress()) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + ZapRaiserRequest( + stringRes(id = R.string.zapraiser), + postViewModel, + ) + } + } + } + } + + postViewModel.userSuggestions?.let { + ShowUserSuggestionList( + it, + postViewModel::autocompleteWithUser, + accountViewModel, + modifier = Modifier.heightIn(0.dp, 300.dp), + ) + } + + postViewModel.emojiSuggestions?.let { + ShowEmojiSuggestionList( + it, + postViewModel::autocompleteWithEmoji, + postViewModel::autocompleteWithEmojiUrl, + accountViewModel, + modifier = Modifier.heightIn(0.dp, 300.dp), + ) + } + + BottomRowActions(postViewModel) + } +} + +@Composable +private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { + val scrollState = rememberScrollState() + Row( + modifier = + Modifier + .horizontalScroll(scrollState) + .fillMaxWidth() + .height(50.dp), + verticalAlignment = CenterVertically, + ) { + SelectFromGallery( + isUploading = postViewModel.isUploadingImage, + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier, + ) { + postViewModel.selectImage(it) + } + + TakePictureButton( + onPictureTaken = { + postViewModel.selectImage(it) + }, + ) + + if (postViewModel.canUsePoll) { + // These should be hashtag recommendations the user selects in the future. + // val hashtag = stringRes(R.string.poll_hashtag) + // postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag) + AddPollButton(postViewModel.wantsPoll) { + postViewModel.wantsPoll = !postViewModel.wantsPoll + } + } + + ForwardZapToButton(postViewModel.wantsForwardZapTo) { + postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo + } + + if (postViewModel.canAddZapRaiser) { + AddZapraiserButton(postViewModel.wantsZapRaiser) { + postViewModel.wantsZapRaiser = !postViewModel.wantsZapRaiser + } + } + + MarkAsSensitiveButton(postViewModel.wantsToMarkAsSensitive) { + postViewModel.toggleMarkAsSensitive() + } + + AddGeoHashButton(postViewModel.wantsToAddGeoHash) { + postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash + } + + AddSecretEmojiButton(postViewModel.wantsSecretEmoji) { + postViewModel.wantsSecretEmoji = !postViewModel.wantsSecretEmoji + } + + if (postViewModel.canAddInvoice && postViewModel.hasLnAddress()) { + AddLnInvoiceButton(postViewModel.wantsInvoice) { + postViewModel.wantsInvoice = !postViewModel.wantsInvoice + } + } + } +} + +@Composable +private fun AddPollButton( + isPollActive: Boolean, + onClick: () -> Unit, +) { + IconButton( + onClick = { onClick() }, + ) { + if (!isPollActive) { + Icon( + painter = painterRes(R.drawable.ic_poll, 1), + contentDescription = stringRes(id = R.string.poll), + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.onBackground, + ) + } else { + Icon( + painter = painterRes(R.drawable.ic_lists, 1), + contentDescription = stringRes(id = R.string.disable_poll), + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.onBackground, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt new file mode 100644 index 0000000000..19966349ec --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -0,0 +1,915 @@ +/** + * 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.ui.screen.loggedIn.home + +import android.content.Context +import android.util.Log +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.compose.currentWord +import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor +import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState.EmojiMedia +import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing +import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState +import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber +import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField +import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewState +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.IZapRaiser +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.IZapField +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.SplitBuilder +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.toZapSplitSetup +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent +import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.experimental.zapPolls.closedAt +import com.vitorpamplona.quartz.experimental.zapPolls.consensusThreshold +import com.vitorpamplona.quartz.experimental.zapPolls.maxAmount +import com.vitorpamplona.quartz.experimental.zapPolls.minAmount +import com.vitorpamplona.quartz.experimental.zapPolls.tags.PollOptionTag +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.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash +import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.people.pTags +import com.vitorpamplona.quartz.nip01Core.tags.references.references +import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip10Notes.content.findHashtags +import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris +import com.vitorpamplona.quartz.nip10Notes.content.findURLs +import com.vitorpamplona.quartz.nip10Notes.tags.notify +import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes +import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji +import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag +import com.vitorpamplona.quartz.nip30CustomEmoji.emojis +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning +import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive +import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW +import com.vitorpamplona.quartz.nip37Drafts.DraftEvent +import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup +import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupLnAddress +import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup +import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits +import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser +import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder +import com.vitorpamplona.quartz.nip92IMeta.imetas +import com.vitorpamplona.quartz.nip94FileMetadata.alt +import com.vitorpamplona.quartz.nip94FileMetadata.blurhash +import com.vitorpamplona.quartz.nip94FileMetadata.dims +import com.vitorpamplona.quartz.nip94FileMetadata.hash +import com.vitorpamplona.quartz.nip94FileMetadata.magnet +import com.vitorpamplona.quartz.nip94FileMetadata.mimeType +import com.vitorpamplona.quartz.nip94FileMetadata.originalHash +import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent +import com.vitorpamplona.quartz.nip94FileMetadata.size +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +enum class UserSuggestionAnchor { + MAIN_MESSAGE, + FORWARD_ZAPS, + TO_USERS, +} + +@Stable +open class ShortNotePostViewModel : + ViewModel(), + ILocationGrabber, + IMessageField, + IZapField, + IZapRaiser { + val draftTag = DraftTagState() + + lateinit var accountViewModel: AccountViewModel + lateinit var account: Account + + init { + viewModelScope.launch(Dispatchers.IO) { + draftTag.versions.collectLatest { + // don't save the first + if (it > 0) { + sendDraftSync() + } + } + } + } + + var originalNote: Note? by mutableStateOf(null) + var forkedFromNote: Note? by mutableStateOf(null) + + var pTags by mutableStateOf?>(null) + var eTags by mutableStateOf?>(null) + + val iMetaAttachments = IMetaAttachments() + var nip95attachments by mutableStateOf>>(emptyList()) + + override var message by mutableStateOf(TextFieldValue("")) + + val urlPreviews = PreviewState() + + var isUploadingImage by mutableStateOf(false) + + var userSuggestions: UserSuggestionState? = null + var userSuggestionsMainMessage: UserSuggestionAnchor? = null + + var emojiSuggestions: EmojiSuggestionState? = null + + // Images and Videos + var multiOrchestrator by mutableStateOf(null) + + // Polls + var canUsePoll by mutableStateOf(false) + var wantsPoll by mutableStateOf(false) + var zapRecipients = mutableStateListOf() + var pollOptions = newStateMapPollOptions() + var valueMaximum by mutableStateOf(null) + var valueMinimum by mutableStateOf(null) + var consensusThreshold: Int? = null + var closedAt: Long? = null + + var isValidValueMaximum = mutableStateOf(true) + var isValidValueMinimum = mutableStateOf(true) + var isValidConsensusThreshold = mutableStateOf(true) + var isValidClosedAt = mutableStateOf(true) + + // Invoices + var canAddInvoice by mutableStateOf(false) + var wantsInvoice by mutableStateOf(false) + + var wantsSecretEmoji by mutableStateOf(false) + + // Forward Zap to + var wantsForwardZapTo by mutableStateOf(false) + override var forwardZapTo = mutableStateOf>(SplitBuilder()) + override var forwardZapToEditting = mutableStateOf(TextFieldValue("")) + + // NSFW, Sensitive + var wantsToMarkAsSensitive by mutableStateOf(false) + + // GeoHash + var wantsToAddGeoHash by mutableStateOf(false) + var location: StateFlow? = null + var wantsExclusiveGeoPost by mutableStateOf(false) + + // ZapRaiser + var canAddZapRaiser by mutableStateOf(false) + var wantsZapRaiser by mutableStateOf(false) + override val zapRaiserAmount = mutableStateOf(null) + + fun lnAddress(): String? = account.userProfile().info?.lnAddress() + + fun hasLnAddress(): Boolean = account.userProfile().info?.lnAddress() != null + + fun user(): User? = account.userProfile() + + open fun init(accountVM: AccountViewModel) { + this.accountViewModel = accountVM + this.account = accountVM.account + this.canAddInvoice = hasLnAddress() + this.canAddZapRaiser = hasLnAddress() + + this.userSuggestions?.reset() + this.userSuggestions = UserSuggestionState(accountVM) + + this.emojiSuggestions?.reset() + this.emojiSuggestions = EmojiSuggestionState(accountVM) + } + + open fun load( + replyingTo: Note?, + quote: Note?, + fork: Note?, + version: Note?, + draft: Note?, + ) { + val noteEvent = draft?.event + val noteAuthor = draft?.author + + if (draft != null && noteEvent is DraftEvent && noteAuthor != null) { + viewModelScope.launch(Dispatchers.IO) { + accountViewModel.createTempDraftNote(noteEvent)?.let { innerNote -> + val oldTag = (draft.event as? AddressableEvent)?.dTag() + if (oldTag != null) { + draftTag.set(oldTag) + } + loadFromDraft(innerNote) + } + } + } else { + originalNote = replyingTo + replyingTo?.let { replyNote -> + if (replyNote.event is BaseThreadedEvent) { + this.eTags = (replyNote.replyTo ?: emptyList()).plus(replyNote) + } else { + this.eTags = listOf(replyNote) + } + + if (replyNote.event !is CommunityDefinitionEvent) { + replyNote.author?.let { replyUser -> + val currentMentions = + (replyNote.event as? TextNoteEvent) + ?.mentions() + ?.map { LocalCache.getOrCreateUser(it.pubKey) } + ?: emptyList() + + if (currentMentions.contains(replyUser)) { + this.pTags = currentMentions + } else { + this.pTags = currentMentions.plus(replyUser) + } + } + } + } + ?: run { + eTags = null + pTags = null + } + + val user = account.userProfile() + + canAddInvoice = user.info?.lnAddress() != null + canAddZapRaiser = user.info?.lnAddress() != null + canUsePoll = originalNote == null + multiOrchestrator = null + + quote?.let { quotedNote -> + message = TextFieldValue(message.text + "\nnostr:${quotedNote.toNEvent()}") + + quotedNote.author?.let { quotedUser -> + if (quotedUser.pubkeyHex != user.pubkeyHex) { + if (forwardZapTo.value.items.none { it.key.pubkeyHex == quotedUser.pubkeyHex }) { + forwardZapTo.value.addItem(quotedUser) + } + if (forwardZapTo.value.items.none { it.key.pubkeyHex == user.pubkeyHex }) { + forwardZapTo.value.addItem(user) + } + + val pos = forwardZapTo.value.items.indexOfFirst { it.key.pubkeyHex == quotedUser.pubkeyHex } + forwardZapTo.value.updatePercentage(pos, 0.9f) + } + } + } + + fork?.let { forkedNoted -> + message = TextFieldValue(version?.event?.content ?: forkedNoted.event?.content ?: "") + + forkedNoted.event?.isSensitiveOrNSFW()?.let { + if (it) wantsToMarkAsSensitive = true + } + + forkedNoted.event?.zapraiserAmount()?.let { + zapRaiserAmount.value = it + } + + forkedNoted.event?.zapSplitSetup()?.let { setup -> + val totalWeight = setup.sumOf { if (it is ZapSplitSetupLnAddress) 0.0 else it.weight } + + setup.forEach { + if (it is ZapSplitSetup) { + forwardZapTo.value.addItem(LocalCache.getOrCreateUser(it.pubKeyHex), (it.weight / totalWeight).toFloat()) + } + } + } + + // Only adds if it is not already set up. + if (forwardZapTo.value.items.isEmpty()) { + forkedNoted.author?.let { forkedAuthor -> + if (forkedAuthor.pubkeyHex != accountViewModel.userProfile().pubkeyHex) { + if (forwardZapTo.value.items.none { it.key.pubkeyHex == forkedAuthor.pubkeyHex }) forwardZapTo.value.addItem(forkedAuthor) + if (forwardZapTo.value.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) forwardZapTo.value.addItem(accountViewModel.userProfile()) + + val pos = forwardZapTo.value.items.indexOfFirst { it.key.pubkeyHex == forkedAuthor.pubkeyHex } + forwardZapTo.value.updatePercentage(pos, 0.8f) + } + } + } + + forkedNoted.author?.let { + if (this.pTags == null) { + this.pTags = listOf(it) + } else if (this.pTags?.contains(it) != true) { + this.pTags = listOf(it) + (this.pTags ?: emptyList()) + } + } + + forkedFromNote = forkedNoted + } ?: run { + forkedFromNote = null + } + + if (!forwardZapTo.value.items.isEmpty()) { + wantsForwardZapTo = true + } + } + + urlPreviews.update(message) + } + + private fun loadFromDraft(draft: Note) { + val draftEvent = draft.event ?: return + if (draftEvent !is TextNoteEvent) return + + loadFromDraft(draftEvent) + } + + private fun loadFromDraft(draftEvent: TextNoteEvent) { + canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null + canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null + multiOrchestrator = null + + val localForwardZapTo = draftEvent.tags.filter { it.size > 1 && it[0] == "zap" } + forwardZapTo.value = SplitBuilder() + localForwardZapTo.forEach { + val user = LocalCache.getOrCreateUser(it[1]) + val value = it.last().toFloatOrNull() ?: 0f + forwardZapTo.value.addItem(user, value) + } + forwardZapToEditting.value = TextFieldValue("") + wantsForwardZapTo = localForwardZapTo.isNotEmpty() + + wantsToMarkAsSensitive = draftEvent.isSensitive() + + val geohash = draftEvent.getGeoHash() + wantsToAddGeoHash = geohash != null + if (geohash != null) { + wantsExclusiveGeoPost = draftEvent.kind == CommentEvent.KIND + } + + val zapRaiser = draftEvent.zapraiserAmount() + wantsZapRaiser = zapRaiser != null + zapRaiserAmount.value = null + if (zapRaiser != null) { + zapRaiserAmount.value = zapRaiser + } + + eTags = + draftEvent.tags.filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) != "fork" }.mapNotNull { + val note = LocalCache.checkGetOrCreateNote(it[1]) + note + } + + pTags = + draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.map { + LocalCache.getOrCreateUser(it[1]) + } + + draftEvent.tags.filter { it.size > 3 && (it[0] == "e" || it[0] == "a") && it[3] == "fork" }.forEach { + val note = LocalCache.checkGetOrCreateNote(it[1]) + forkedFromNote = note + } + + originalNote = + draftEvent + .tags + .filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) == "reply" } + .map { + LocalCache.checkGetOrCreateNote(it[1]) + }.firstOrNull() + + if (originalNote == null) { + originalNote = + draftEvent + .tags + .filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) == "root" } + .map { + LocalCache.checkGetOrCreateNote(it[1]) + }.firstOrNull() + } + + canUsePoll = originalNote == null + + if (forwardZapTo.value.items.isNotEmpty()) { + wantsForwardZapTo = true + } + + val polls = draftEvent.tags.filter { it.size > 1 && it[0] == "poll_option" } + wantsPoll = polls.isNotEmpty() + + polls.forEach { + pollOptions[it[1].toInt()] = it[2] + } + + val minMax = draftEvent.tags.filter { it.size > 1 && (it[0] == "value_minimum" || it[0] == "value_maximum") } + minMax.forEach { + if (it[0] == "value_maximum") { + valueMaximum = it[1].toLong() + } else if (it[0] == "value_minimum") { + valueMinimum = it[1].toLong() + } + } + + message = TextFieldValue(draftEvent.content) + + iMetaAttachments.addAll(draftEvent.imetas()) + + urlPreviews.update(message) + } + + suspend fun sendPostSync() { + val template = createTemplate() ?: return + val extraNotesToBroadcast = mutableListOf() + + if (nip95attachments.isNotEmpty()) { + val usedImages = template.tags.taggedQuoteIds().toSet() + nip95attachments.forEach { + if (usedImages.contains(it.second.id)) { + extraNotesToBroadcast.add(it.first) + extraNotesToBroadcast.add(it.second) + } + } + } + + val version = draftTag.current + cancel() + + accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast) + accountViewModel.deleteDraft(version) + } + + suspend fun sendDraftSync() { + val accountViewModel = accountViewModel + + if (message.text.isBlank()) { + accountViewModel.account.deleteDraft(draftTag.current) + } else { + val template = createTemplate() ?: return + accountViewModel.account.createAndSendDraft(draftTag.current, template) + + nip95attachments.forEach { + account.sendToPrivateOutboxAndLocal(it.first) + account.sendToPrivateOutboxAndLocal(it.second) + } + } + } + + private suspend fun createTemplate(): EventTemplate? { + val tagger = + NewMessageTagger( + message.text, + pTags, + eTags, + accountViewModel, + ) + tagger.run() + + val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null + + val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() + val localZapRaiserAmount = if (wantsZapRaiser) zapRaiserAmount.value else null + + val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value) + val urls = findURLs(tagger.message) + val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) + + val contentWarningReason = if (wantsToMarkAsSensitive) "" else null + + return if (wantsPoll) { + val options = pollOptions.map { PollOptionTag(it.key, it.value) } + + if (options.isEmpty()) return null + + val quotes = findNostrUris(tagger.message) + + PollNoteEvent.build(tagger.message, options) { + valueMinimum?.let { minAmount(it) } + valueMaximum?.let { maxAmount(it) } + closedAt?.let { closedAt(it) } + consensusThreshold?.let { consensusThreshold(it / 100.0) } + + pTags(tagger.directMentionsUsers.map { it.toPTag() }) + quotes(quotes) + hashtags(findHashtags(tagger.message)) + + geoHash?.let { geohash(it) } + localZapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + contentWarningReason?.let { contentWarning(it) } + + emojis(emojis) + imetas(usedAttachments) + } + } else { + TextNoteEvent.build( + note = tagger.message, + replyingTo = originalNote?.toEventHint(), + forkingFrom = forkedFromNote?.toEventHint(), + ) { + tagger.pTags?.let { pTagList -> notify(pTagList.map { it.toPTag() }) } + + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + geoHash?.let { geohash(it) } + localZapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + contentWarningReason?.let { contentWarning(it) } + + emojis(emojis) + imetas(usedAttachments) + } + } + } + + fun findEmoji( + message: String, + myEmojiSet: List?, + ): List { + if (myEmojiSet == null) return emptyList() + return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> + myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link.url) } + } + } + + fun upload( + alt: String?, + contentWarningReason: String?, + mediaQuality: Int, + server: ServerName, + onError: (title: String, message: String) -> Unit, + context: Context, + ) = try { + uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context) + } catch (_: SignerExceptions.ReadOnlyException) { + onError( + stringRes(context, R.string.read_only_user), + stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events), + ) + } + + fun uploadUnsafe( + alt: String?, + contentWarningReason: String?, + mediaQuality: Int, + server: ServerName, + onError: (title: String, message: String) -> Unit, + context: Context, + ) { + viewModelScope.launch(Dispatchers.Default) { + val myMultiOrchestrator = multiOrchestrator ?: return@launch + + isUploadingImage = true + + val results = + myMultiOrchestrator.upload( + alt, + contentWarningReason, + MediaCompressor.intToCompressorQuality(mediaQuality), + server, + account, + context, + ) + + if (results.allGood) { + results.successful.forEach { state -> + if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) { + val nip95 = account.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, contentWarningReason) + nip95attachments = nip95attachments + nip95 + val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) } + + note?.let { + message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) + urlPreviews.update(message) + } + } else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { + val iMeta = + IMetaTagBuilder(state.result.url) + .apply { + hash(state.result.fileHeader.hash) + size(state.result.fileHeader.size) + state.result.fileHeader.mimeType + ?.let { mimeType(it) } + state.result.fileHeader.dim + ?.let { dims(it) } + state.result.fileHeader.blurHash + ?.let { blurhash(it.blurhash) } + state.result.magnet?.let { magnet(it) } + state.result.uploadedHash?.let { originalHash(it) } + + alt?.let { alt(it) } + contentWarningReason?.let { sensitiveContent(contentWarningReason) } + }.build() + + iMetaAttachments.replace(iMeta.url, iMeta) + + message = message.insertUrlAtCursor(state.result.url) + urlPreviews.update(message) + } + } + + multiOrchestrator = null + } else { + val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct() + onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) + } + + isUploadingImage = false + } + } + + open fun cancel() { + draftTag.rotate() + + message = TextFieldValue("") + + forkedFromNote = null + + multiOrchestrator = null + isUploadingImage = false + pTags = null + + wantsPoll = false + zapRecipients = mutableStateListOf() + pollOptions = newStateMapPollOptions() + valueMaximum = null + valueMinimum = null + consensusThreshold = null + closedAt = null + + wantsInvoice = false + wantsZapRaiser = false + zapRaiserAmount.value = null + + wantsForwardZapTo = false + wantsToMarkAsSensitive = false + wantsToAddGeoHash = false + wantsExclusiveGeoPost = false + wantsSecretEmoji = false + + forwardZapTo.value = SplitBuilder() + forwardZapToEditting.value = TextFieldValue("") + + urlPreviews.reset() + + userSuggestions?.reset() + userSuggestionsMainMessage = null + + iMetaAttachments.reset() + + emojiSuggestions?.reset() + } + + fun deleteMediaToUpload(selected: SelectedMediaProcessing) { + this.multiOrchestrator?.remove(selected) + } + + open fun removeFromReplyList(userToRemove: User) { + pTags = pTags?.filter { it != userToRemove } + } + + open fun addToMessage(it: String) { + updateMessage(TextFieldValue(message.text + " " + it)) + } + + override fun updateMessage(newMessage: TextFieldValue) { + message = newMessage + urlPreviews.update(message) + + if (message.selection.collapsed) { + val lastWord = message.currentWord() + + userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE + userSuggestions?.processCurrentWord(lastWord) + + emojiSuggestions?.processCurrentWord(lastWord) + } + + draftTag.newVersion() + } + + override fun updateZapForwardTo(newZapForwardTo: TextFieldValue) { + forwardZapToEditting.value = newZapForwardTo + if (newZapForwardTo.selection.collapsed) { + val lastWord = newZapForwardTo.text + userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS + userSuggestions?.processCurrentWord(lastWord) + } + } + + open fun autocompleteWithUser(item: User) { + userSuggestions?.let { userSuggestions -> + if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { + val lastWord = message.currentWord() + message = userSuggestions.replaceCurrentWord(message, lastWord, item) + urlPreviews.update(message) + } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { + forwardZapTo.value.addItem(item) + forwardZapToEditting.value = TextFieldValue("") + } + + userSuggestionsMainMessage = null + userSuggestions.reset() + } + + draftTag.newVersion() + } + + open fun autocompleteWithEmoji(item: EmojiMedia) { + val wordToInsert = ":${item.code}:" + + message = message.replaceCurrentWord(wordToInsert) + urlPreviews.update(message) + + emojiSuggestions?.reset() + + draftTag.newVersion() + } + + open fun autocompleteWithEmojiUrl(item: EmojiMedia) { + val wordToInsert = item.link.url + " " + + viewModelScope.launch(Dispatchers.IO) { + iMetaAttachments.downloadAndPrepare( + item.link.url, + ) { + Amethyst.instance.okHttpClients.getHttpClient( + accountViewModel.account.privacyState.shouldUseTorForImageDownload(item.link.url), + ) + } + } + + message = message.replaceCurrentWord(wordToInsert) + urlPreviews.update(message) + + emojiSuggestions?.reset() + + draftTag.newVersion() + } + + private fun newStateMapPollOptions(): SnapshotStateMap = mutableStateMapOf(Pair(0, ""), Pair(1, "")) + + fun canPost(): Boolean = + message.text.isNotBlank() && + !isUploadingImage && + !wantsInvoice && + (!wantsZapRaiser || zapRaiserAmount.value != null) && + ( + !wantsPoll || + ( + pollOptions.values.all { it.isNotEmpty() } && + isValidValueMinimum.value && + isValidValueMaximum.value + ) + ) && + multiOrchestrator == null + + fun insertAtCursor(newElement: String) { + message = message.insertUrlAtCursor(newElement) + } + + fun selectImage(uris: ImmutableList) { + multiOrchestrator = MultiOrchestrator(uris) + } + + override fun locationFlow(): StateFlow { + if (location == null) { + location = locationManager().geohashStateFlow + } + + return location!! + } + + override fun onCleared() { + super.onCleared() + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + } + + fun updateMinZapAmountForPoll(textMin: String) { + valueMinimum = textMin.toLongOrNull()?.takeIf { it > 0 } + checkMinMax() + draftTag.newVersion() + } + + fun updateMaxZapAmountForPoll(textMax: String) { + valueMaximum = textMax.toLongOrNull()?.takeIf { it > 0 } + checkMinMax() + draftTag.newVersion() + } + + fun checkMinMax() { + if ((valueMinimum ?: 0) > (valueMaximum ?: Long.MAX_VALUE)) { + isValidValueMinimum.value = false + isValidValueMaximum.value = false + } else { + isValidValueMinimum.value = true + isValidValueMaximum.value = true + } + } + + override fun updateZapPercentage( + index: Int, + sliderValue: Float, + ) { + forwardZapTo.value.updatePercentage(index, sliderValue) + } + + override fun updateZapFromText() { + viewModelScope.launch(Dispatchers.Default) { + val tagger = + NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel) + tagger.run() + tagger.pTags?.forEach { taggedUser -> + if (!forwardZapTo.value.items.any { it.key == taggedUser }) { + forwardZapTo.value.addItem(taggedUser) + } + } + } + } + + override fun updateZapRaiserAmount(newAmount: Long?) { + zapRaiserAmount.value = newAmount + draftTag.newVersion() + } + + fun removePollOption(optionIndex: Int) { + pollOptions.removeOrdered(optionIndex) + draftTag.newVersion() + } + + private fun MutableMap.removeOrdered(index: Int) { + val keyList = keys + val elementList = values.toMutableList() + run stop@{ + for (i in index until elementList.size) { + val nextIndex = i + 1 + if (nextIndex == elementList.size) return@stop + elementList[i] = elementList[nextIndex].also { elementList[nextIndex] = "null" } + } + } + elementList.removeAt(elementList.size - 1) + val newEntries = keyList.zip(elementList) { key, content -> Pair(key, content) } + this.clear() + this.putAll(newEntries) + } + + fun updatePollOption( + optionIndex: Int, + text: String, + ) { + pollOptions[optionIndex] = text + draftTag.newVersion() + } + + fun toggleMarkAsSensitive() { + wantsToMarkAsSensitive = !wantsToMarkAsSensitive + draftTag.newVersion() + } + + override fun locationManager(): LocationState = Amethyst.instance.locationManager +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt similarity index 80% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt index e057bc091e..0ff7d6fab8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt @@ -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,19 +18,23 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent class HomeConversationsFeedFilter( val account: Account, @@ -38,8 +42,8 @@ class HomeConversationsFeedFilter( override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultHomeFollowList.value override fun showHiddenKey(): Boolean = - account.settings.defaultHomeFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - account.settings.defaultHomeFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + account.liveHomeFollowLists.value is MutedAuthorsByOutboxTopNavFilter || + account.liveHomeFollowLists.value is MutedAuthorsByProxyTopNavFilter override fun feed(): List { val filterParams = buildFilterParams(account) @@ -55,10 +59,8 @@ class HomeConversationsFeedFilter( fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( - userHex = account.userProfile().pubkeyHex, - selectedListName = account.settings.defaultHomeFollowList.value, followLists = account.liveHomeFollowLists.value, - hiddenUsers = account.flowHiddenUsers.value, + hiddenUsers = account.hiddenUsers.flow.value, ) private fun innerApplyFilter(collection: Collection): Set { @@ -78,6 +80,7 @@ class HomeConversationsFeedFilter( event is PollNoteEvent || event is ChannelMessageEvent || event is CommentEvent || + event is VoiceReplyEvent || event is LiveActivitiesChatMessageEvent ) && filterParams.match(event) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt new file mode 100644 index 0000000000..b7e819a57e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt @@ -0,0 +1,172 @@ +/** + * 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.ui.screen.loggedIn.home.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByProxyTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByProxyTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter +import com.vitorpamplona.amethyst.ui.dal.AdditiveComplexFeedFilter +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.TimeUtils + +class HomeLiveFilter( + val account: Account, +) : AdditiveComplexFeedFilter() { + override fun feedKey(): String = account.userProfile().pubkeyHex + + override fun showHiddenKey(): Boolean = false + + fun buildFilterParams(account: Account): FilterByListParams = + FilterByListParams.create( + followLists = account.liveHomeFollowLists.value, + hiddenUsers = account.hiddenUsers.flow.value, + ) + + fun limitTime() = TimeUtils.fifteenMinutesAgo() + + override fun feed(): List { + val filterParams = buildFilterParams(account) + val fiveMinsAgo = limitTime() + + val list = + LocalCache.ephemeralChannels.filter { id, channel -> + shouldIncludeChannel(channel, filterParams, fiveMinsAgo) + } + + return sort(list.toSet()) + } + + fun shouldIncludeChannel( + channel: EphemeralChatChannel, + filterParams: FilterByListParams, + timeLimit: Long, + ): Boolean = + channel.notes + .filter { key, value -> + acceptableEvent(value, filterParams, timeLimit) + }.isNotEmpty() + + override fun updateListWith( + oldList: List, + newItems: Set, + ): List { + val fiveMinsAgo = limitTime() + + val revisedOldList = + oldList.filter { channel -> + (channel.lastNote?.createdAt() ?: 0) > fiveMinsAgo + } + + val newItemsToBeAdded = applyFilter(newItems) + return if (newItemsToBeAdded.isNotEmpty()) { + val channelsToAdd = + newItemsToBeAdded + .mapNotNull { + val room = (it.event as? EphemeralChatEvent)?.roomId() + if (room != null) { + LocalCache.getEphemeralChatChannelIfExists(room) + } else { + null + } + } + + val newList = revisedOldList.toSet() + channelsToAdd + sort(newList).take(limit()) + } else { + revisedOldList + } + } + + private fun applyFilter(collection: Collection): Set { + val filterParams = buildFilterParams(account) + + return collection.filterTo(HashSet()) { + acceptableEvent(it, filterParams, limitTime()) + } + } + + private fun acceptableEvent( + note: Note, + filterParams: FilterByListParams, + timeLimit: Long, + ): Boolean { + val createdAt = note.createdAt() ?: return false + val noteEvent = note.event + return (noteEvent is EphemeralChatEvent) && + createdAt > timeLimit && + filterParams.match(noteEvent, note.relays) + } + + fun sort(collection: Set): List { + val topFilter = account.liveHomeFollowLists.value + val topFilterAuthors = + when (topFilter) { + is AuthorsByOutboxTopNavFilter -> topFilter.authors + is MutedAuthorsByOutboxTopNavFilter -> topFilter.authors + is AllFollowsByOutboxTopNavFilter -> topFilter.authors + is SingleCommunityTopNavFilter -> topFilter.authors + is AuthorsByProxyTopNavFilter -> topFilter.authors + is MutedAuthorsByProxyTopNavFilter -> topFilter.authors + is AllFollowsByProxyTopNavFilter -> topFilter.authors + else -> null + } + + val followingKeySet = topFilterAuthors ?: account.kind3FollowList.flow.value.authors + + val followCounts = + collection.associateWith { followsThatParticipateOn(it, followingKeySet) } + + return collection.sortedWith( + compareByDescending { followCounts[it] } + .thenByDescending { it.lastNote?.createdAt() ?: 0 } + .thenBy { it.roomId.id } + .thenBy { it.roomId.relayUrl }, + ) + } + + fun followsThatParticipateOn( + channel: EphemeralChatChannel, + followingSet: Set?, + ): Int { + var count = 0 + + channel.notes.forEach { key, value -> + val author = value.author + if (author != null) { + if (followingSet == null || author.pubkeyHex in followingSet) { + count++ + } + } + } + + return count + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt similarity index 78% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt index f1c079d0b5..a92e458716 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt @@ -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,25 +18,30 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent class HomeNewThreadFeedFilter( val account: Account, @@ -44,30 +49,27 @@ class HomeNewThreadFeedFilter( override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultHomeFollowList.value override fun showHiddenKey(): Boolean = - account.settings.defaultHomeFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - account.settings.defaultHomeFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + account.liveHomeFollowLists.value is MutedAuthorsByOutboxTopNavFilter || + account.liveHomeFollowLists.value is MutedAuthorsByProxyTopNavFilter fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( - userHex = account.userProfile().pubkeyHex, - selectedListName = account.settings.defaultHomeFollowList.value, followLists = account.liveHomeFollowLists.value, - hiddenUsers = account.flowHiddenUsers.value, + hiddenUsers = account.hiddenUsers.flow.value, ) override fun feed(): List { - val gRelays = account.activeGlobalRelays().toSet() val filterParams = buildFilterParams(account) val notes = LocalCache.notes.filterIntoSet { _, note -> // Avoids processing addressables twice. - (note.event?.kind ?: 99999) < 10000 && acceptableEvent(note, gRelays, filterParams) + (note.event?.kind ?: 99999) < 10000 && acceptableEvent(note, filterParams) } val longFormNotes = LocalCache.addressables.filterIntoSet { _, note -> - acceptableEvent(note, gRelays, filterParams) + acceptableEvent(note, filterParams) } return sort(notes + longFormNotes) @@ -76,21 +78,18 @@ class HomeNewThreadFeedFilter( override fun applyFilter(collection: Set): Set = innerApplyFilter(collection) private fun innerApplyFilter(collection: Collection): Set { - val gRelays = account.activeGlobalRelays().toSet() val filterParams = buildFilterParams(account) return collection.filterTo(HashSet()) { - acceptableEvent(it, gRelays, filterParams) + acceptableEvent(it, filterParams) } } private fun acceptableEvent( it: Note, - globalRelays: Set, filterParams: FilterByListParams, ): Boolean { val noteEvent = it.event - val isGlobalRelay = it.relays.any { globalRelays.contains(it.url) } return ( noteEvent is TextNoteEvent || noteEvent is ClassifiedsEvent || @@ -100,12 +99,14 @@ class HomeNewThreadFeedFilter( (noteEvent is WikiNoteEvent && noteEvent.content.isNotEmpty()) || noteEvent is PollNoteEvent || noteEvent is HighlightEvent || + (noteEvent is PublicMessageEvent && noteEvent.content.isNotEmpty() && noteEvent.isIncluded(account.signer.pubKey)) || noteEvent is InteractiveStoryPrologueEvent || noteEvent is CommentEvent || noteEvent is AudioTrackEvent || + noteEvent is VoiceEvent || noteEvent is AudioHeaderEvent ) && - filterParams.match(noteEvent, isGlobalRelay) && + filterParams.match(noteEvent, it.relays) && it.isNewThread() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt new file mode 100644 index 0000000000..e3c7c339e9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt @@ -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.ui.screen.loggedIn.home.datasource + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomeOutboxEventsEoseManager +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import kotlinx.coroutines.CoroutineScope + +// This allows multiple screen to be listening to tags, even the same tag +class HomeQueryState( + val account: Account, + val feedState: AccountFeedContentStates, + val scope: CoroutineScope, +) + +class HomeFilterAssembler( + client: NostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + HomeOutboxEventsEoseManager(client, ::allKeys), + // HomeOutboxUsersEoseManager(client, ::allKeys), + // We can break it down, one for each sub if needed. + // HashtagEventsFilterSubAssembler(client, ::allKeys), + // GeohashEventsFilterSubAssembler(client, ::allKeys), + // CommunityEventsFilterSubAssembler(client, ::allKeys), + // MixGeohashHashtagsCommunityEoseManager(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } + + override fun printStats() = group.forEach { it.printStats() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..34c15b5705 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssemblerSubscription.kt @@ -0,0 +1,54 @@ +/** + * 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.ui.screen.loggedIn.home.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun HomeFilterAssemblerSubscription(accountViewModel: AccountViewModel) { + HomeFilterAssemblerSubscription( + accountViewModel.dataSources().home, + accountViewModel, + ) +} + +@Composable +fun HomeFilterAssemblerSubscription( + dataSource: HomeFilterAssembler, + accountViewModel: AccountViewModel, +) { + // different screens get different states + // even if they are tracking the same tag. + val state = + remember(accountViewModel.account) { + HomeQueryState( + accountViewModel.account, + accountViewModel.feedStates, + accountViewModel.viewModelScope, + ) + } + + KeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGeohashes.kt new file mode 100644 index 0000000000..a5e07906d6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGeohashes.kt @@ -0,0 +1,102 @@ +/** + * 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.ui.screen.loggedIn.home.datasource.nip01Core + +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.filterHomePostsByScopes +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import kotlin.collections.flatten + +val HomePostsByGeohashKinds = + listOf( + TextNoteEvent.KIND, + RepostEvent.KIND, + GenericRepostEvent.KIND, + LongTextNoteEvent.KIND, + ClassifiedsEvent.KIND, + HighlightEvent.KIND, + InteractiveStoryPrologueEvent.KIND, + WikiNoteEvent.KIND, + CommentEvent.KIND, + VoiceEvent.KIND, + VoiceReplyEvent.KIND, + ) + +fun filterHomePostsByGeohashes( + relay: NormalizedRelayUrl, + geotags: Set, + since: Long?, +): List { + if (geotags.isEmpty()) return emptyList() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = HomePostsByGeohashKinds, + tags = mapOf("g" to geotags.sorted()), + limit = 100, + since = since, + ), + ), + ) +} + +fun filterHomePostsByGeohashes( + geoSet: LocationTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long?, +): List { + if (geoSet.set.isEmpty()) return emptyList() + + return geoSet.set + .mapNotNull { + if (it.value.geotags.isEmpty()) { + null + } else { + filterHomePostsByGeohashes( + relay = it.key, + geotags = it.value.geotags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + + filterHomePostsByScopes( + relay = it.key, + scopesToLoad = it.value.geotagScopes, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGlobal.kt new file mode 100644 index 0000000000..a590028396 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGlobal.kt @@ -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.ui.screen.loggedIn.home.datasource.nip01Core + +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomePostsConversationKinds +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomePostsNewThreadKinds +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter + +fun filterHomePostsByGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + newThreadSince: Long?, + repliesSince: Long?, +): List { + if (relays.set.isEmpty()) return emptyList() + + return relays.set.flatMap { + val since = since?.get(it.key)?.time + val relayUrl = it.key + listOf( + RelayBasedFilter( + relay = relayUrl, + filter = + Filter( + kinds = HomePostsNewThreadKinds, + limit = 50, + since = since ?: newThreadSince, + ), + ), + RelayBasedFilter( + relay = relayUrl, + filter = + Filter( + kinds = HomePostsConversationKinds, + limit = 50, + since = since ?: repliesSince, + ), + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByHashtags.kt new file mode 100644 index 0000000000..8e9eddd2be --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByHashtags.kt @@ -0,0 +1,107 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core + +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.filterHomePostsByScopes +import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent + +val HomePostsBuHashtagsKinds = + listOf( + TextNoteEvent.KIND, + RepostEvent.KIND, + GenericRepostEvent.KIND, + LongTextNoteEvent.KIND, + ClassifiedsEvent.KIND, + HighlightEvent.KIND, + AudioHeaderEvent.KIND, + InteractiveStoryPrologueEvent.KIND, + CommentEvent.KIND, + WikiNoteEvent.KIND, + VoiceEvent.KIND, + VoiceReplyEvent.KIND, + ) + +fun filterHomePostsByHashtags( + relay: NormalizedRelayUrl, + hashToLoad: Set, + since: Long?, +): List { + if (hashToLoad.isEmpty()) return emptyList() + + val hashtags = hashToLoad.flatMap { hashtagAlts(it) }.distinct().sorted() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = HomePostsBuHashtagsKinds, + tags = mapOf("t" to hashtags), + limit = 100, + since = since, + ), + ), + ) +} + +fun filterHomePostsByHashtags( + hashtagSet: HashtagTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long?, +): List { + if (hashtagSet.set.isEmpty()) return emptyList() + + return hashtagSet.set + .mapNotNull { + if (it.value.hashtags.isEmpty()) { + null + } else { + val since = since?.get(it.key)?.time ?: defaultSince + return filterHomePostsByHashtags( + relay = it.key, + hashToLoad = it.value.hashtags, + since = since, + ) + + filterHomePostsByScopes( + relay = it.key, + scopesToLoad = it.value.hashtagScopes, + since = since, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip22Comments/FilterPostsByScopes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip22Comments/FilterPostsByScopes.kt new file mode 100644 index 0000000000..04172ee0db --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip22Comments/FilterPostsByScopes.kt @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments + +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip22Comments.CommentEvent + +val CommentKinds = listOf(CommentEvent.KIND) + +fun filterHomePostsByScopes( + relay: NormalizedRelayUrl, + scopesToLoad: Set, + since: Long?, +): List { + if (scopesToLoad.isEmpty()) return emptyList() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommentKinds, + tags = mapOf("I" to scopesToLoad.toList()), + limit = 100, + since = since, + ), + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAllFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAllFollows.kt new file mode 100644 index 0000000000..8c6a77b459 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAllFollows.kt @@ -0,0 +1,66 @@ +/** + * 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.ui.screen.loggedIn.home.datasource.nip65Follows + +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.filterHomePostsByGeohashes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.filterHomePostsByHashtags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.filterHomePostsByScopes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsFromAllCommunities +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun filterHomePostsByAllFollows( + followsSet: AllFollowsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + sinceBoundaryNew: Long?, + sinceBoundaryReply: Long?, +): List { + if (followsSet.set.isEmpty()) return emptyList() + + return followsSet.set.flatMap { (relay, filter) -> + val since = since?.get(relay)?.time + + listOfNotNull( + filter.authors?.let { + filterNewHomePostsByAuthors(relay, it, since ?: sinceBoundaryNew) + }, + filter.authors?.let { + filterReplyHomePostsByAuthors(relay, it, since ?: sinceBoundaryReply) + }, + filter.geotags?.let { + filterHomePostsByGeohashes(relay, it, since ?: sinceBoundaryNew) + }, + filter.geotagScopes?.let { + filterHomePostsByScopes(relay, it, since ?: sinceBoundaryNew) + }, + filter.hashtags?.let { + filterHomePostsByHashtags(relay, it, since ?: sinceBoundaryNew) + }, + filter.hashtagScopes?.let { + filterHomePostsByScopes(relay, it, since ?: sinceBoundaryNew) + }, + filter.communities?.let { + filterHomePostsFromAllCommunities(relay, it, since ?: sinceBoundaryNew) + }, + ).flatten() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt new file mode 100644 index 0000000000..b9de073882 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt @@ -0,0 +1,164 @@ +/** + * 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.ui.screen.loggedIn.home.datasource.nip65Follows + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import kotlin.math.max + +val HomePostsNewThreadKinds = + listOf( + TextNoteEvent.KIND, + RepostEvent.KIND, + GenericRepostEvent.KIND, + ClassifiedsEvent.KIND, + LongTextNoteEvent.KIND, + HighlightEvent.KIND, + WikiNoteEvent.KIND, + PollNoteEvent.KIND, + InteractiveStoryPrologueEvent.KIND, + ) + +val HomePostsConversationKinds = + listOf( + LiveActivitiesChatMessageEvent.KIND, + CommentEvent.KIND, + LiveActivitiesEvent.KIND, + EphemeralChatEvent.KIND, + VoiceEvent.KIND, + VoiceReplyEvent.KIND, + ) + +fun filterNewHomePostsByAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = HomePostsNewThreadKinds, + authors = authorList, + limit = max(authorList.size * 10, 300), + since = since, + ), + ), + ) +} + +fun filterReplyHomePostsByAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = HomePostsConversationKinds, + authors = authorList, + limit = max(authorList.size * 10, 300), + since = since, + ), + ), + ) +} + +fun filterHomePostsByAuthors( + authorSet: AuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + sinceBoundaryNew: Long?, + sinceBoundaryReply: Long?, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterNewHomePostsByAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: sinceBoundaryNew, + ) + + filterReplyHomePostsByAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: sinceBoundaryReply, + ) + } + }.flatten() +} + +fun filterHomePostsByAuthors( + authorSet: MutedAuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + sinceBoundaryNew: Long?, + sinceBoundaryReply: Long?, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterNewHomePostsByAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: sinceBoundaryNew, + ) + + filterReplyHomePostsByAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: sinceBoundaryReply, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt new file mode 100644 index 0000000000..10803e6f10 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt @@ -0,0 +1,126 @@ +/** + * 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.ui.screen.loggedIn.home.datasource.nip65Follows + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeQueryState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.filterHomePostsByGeohashes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.filterHomePostsByGlobal +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.filterHomePostsByHashtags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsByAllCommunities +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsByCommunity +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch + +class HomeOutboxEventsEoseManager( + client: NostrClient, + allKeys: () -> Set, +) : PerUserAndFollowListEoseManager(client, allKeys) { + override fun updateFilter( + key: HomeQueryState, + since: SincePerRelayMap?, + ): List? { + val feedSettings = key.followsPerRelay() + val newThreadSince = key.feedState.homeNewThreads.lastNoteCreatedAtIfFilled() + val repliesSince = key.feedState.homeReplies.lastNoteCreatedAtIfFilled() + return when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterHomePostsByAllCommunities(feedSettings, since, newThreadSince) + is AllFollowsTopNavPerRelayFilterSet -> filterHomePostsByAllFollows(feedSettings, since, newThreadSince, repliesSince) + is AuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince) + is GlobalTopNavPerRelayFilterSet -> filterHomePostsByGlobal(feedSettings, since, newThreadSince, repliesSince) + is HashtagTopNavPerRelayFilterSet -> filterHomePostsByHashtags(feedSettings, since, newThreadSince) + is LocationTopNavPerRelayFilterSet -> filterHomePostsByGeohashes(feedSettings, since, newThreadSince) + is MutedAuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince) + is SingleCommunityTopNavPerRelayFilterSet -> filterHomePostsByCommunity(feedSettings, since, newThreadSince) + else -> emptyList() + } + } + + override fun user(key: HomeQueryState) = key.account.userProfile() + + override fun list(key: HomeQueryState) = key.listName() + + fun HomeQueryState.listNameFlow() = account.settings.defaultHomeFollowList + + fun HomeQueryState.listName() = listNameFlow().value + + fun HomeQueryState.followRelayFlow() = account.liveHomeFollowListsPerRelay + + fun HomeQueryState.followsPerRelay() = followRelayFlow().value + + val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: HomeQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.scope.launch(Dispatchers.Default) { + key.listNameFlow().collectLatest { + invalidateFilters() + } + }, + key.scope.launch(Dispatchers.Default) { + key.followRelayFlow().sample(1000).collectLatest { + invalidateFilters() + } + }, + key.account.scope.launch(Dispatchers.Default) { + key.feedState.homeNewThreads.lastNoteCreatedAtWhenFullyLoaded.sample(1000).collectLatest { + invalidateFilters() + } + }, + key.account.scope.launch(Dispatchers.Default) { + key.feedState.homeReplies.lastNoteCreatedAtWhenFullyLoaded.sample(1000).collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsByAllCommunities.kt new file mode 100644 index 0000000000..98fc5cc52f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsByAllCommunities.kt @@ -0,0 +1,82 @@ +/** + * 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.ui.screen.loggedIn.home.datasource.nip72Communities + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent + +fun filterHomePostsFromAllCommunities( + relay: NormalizedRelayUrl, + communities: Set, + since: Long? = null, +): List { + val communityList = communities.sorted() + + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to communityList, + "k" to HomePostsFromCommunityKindsStr, + ), + limit = communityList.size * 20, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("a" to communityList), + kinds = HomePostsFromCommunityKinds, + limit = communityList.size * 20, + since = since, + ), + ), + ) +} + +fun filterHomePostsByAllCommunities( + communitySet: AllCommunitiesTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterHomePostsFromAllCommunities( + relay = it.key, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsFromCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsFromCommunities.kt new file mode 100644 index 0000000000..a914febf50 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsFromCommunities.kt @@ -0,0 +1,116 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent + +val HomePostsFromCommunityKinds = + listOf( + TextNoteEvent.KIND, + LongTextNoteEvent.KIND, + ClassifiedsEvent.KIND, + HighlightEvent.KIND, + WikiNoteEvent.KIND, + CommunityPostApprovalEvent.KIND, + CommentEvent.KIND, + InteractiveStoryPrologueEvent.KIND, + ) + +val HomePostsFromCommunityKindsStr = + listOf( + TextNoteEvent.KIND.toString(), + LongTextNoteEvent.KIND.toString(), + ClassifiedsEvent.KIND.toString(), + HighlightEvent.KIND.toString(), + WikiNoteEvent.KIND.toString(), + CommunityPostApprovalEvent.KIND.toString(), + CommentEvent.KIND.toString(), + InteractiveStoryPrologueEvent.KIND.toString(), + ) + +fun filterHomePostsFromCommunity( + relay: NormalizedRelayUrl, + community: String, + authors: Set?, + since: Long? = null, +): List { + val authors = authors?.sorted() + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to listOf(community), + "k" to HomePostsFromCommunityKindsStr, + ), + limit = 100, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + tags = mapOf("a" to listOf(community)), + kinds = HomePostsFromCommunityKinds, + limit = 100, + since = since, + ), + ), + ) +} + +fun filterHomePostsByCommunity( + communitySet: SingleCommunityTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterHomePostsFromCommunity( + relay = it.key, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/RenderEphemeralBubble.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/RenderEphemeralBubble.kt new file mode 100644 index 0000000000..e56e00ac3a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/RenderEphemeralBubble.kt @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.live + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannelNoteAuthors +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.note.Gallery +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer + +@Composable +fun RenderEphemeralBubble( + channel: EphemeralChatChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + FilledTonalButton( + contentPadding = PaddingValues(start = 8.dp, end = 10.dp, bottom = 0.dp, top = 0.dp), + onClick = { + nav.nav { routeFor(channel) } + }, + ) { + RenderUsers(channel, accountViewModel, nav) + Spacer(StdHorzSpacer) + Text( + channel.toBestDisplayName(), + ) + } +} + +@Composable +fun RenderUsers( + channel: EphemeralChatChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val authors by observeChannelNoteAuthors(channel, accountViewModel) + + Gallery(authors, Modifier, accountViewModel, nav, 3) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupDialog.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupDialog.kt index e92b950282..4d6e95a212 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupDialog.kt @@ -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,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn +package com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup import android.app.Activity import android.content.Context @@ -72,7 +72,6 @@ import androidx.compose.ui.platform.LocalAutofill import androidx.compose.ui.platform.LocalAutofillTree import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType @@ -94,6 +93,9 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.note.authenticate +import com.vitorpamplona.amethyst.ui.painterRes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.BackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer import com.vitorpamplona.amethyst.ui.stringRes @@ -501,7 +503,7 @@ private fun QrCodeButtonBase( }, ) { Icon( - painter = painterResource(R.drawable.ic_qrcode), + painter = painterRes(R.drawable.ic_qrcode, 4), contentDescription = stringRes(id = contentDescription), modifier = Modifier.size(24.dp), tint = if (isEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.grayText, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt index 7bb7a6da65..59e3e78011 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt @@ -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,6 +25,7 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableStateOf +import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note @@ -33,9 +34,9 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrderCard import com.vitorpamplona.amethyst.ui.dal.FeedFilter -import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent import com.vitorpamplona.amethyst.ui.feeds.LoadedFeedState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter import com.vitorpamplona.ammolite.relays.BundledInsert import com.vitorpamplona.ammolite.relays.BundledUpdate import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent @@ -57,7 +58,6 @@ import kotlinx.coroutines.launch import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter -import kotlin.time.measureTimedValue @Stable class CardFeedContentState( @@ -72,10 +72,12 @@ class CardFeedContentState( val scrollToTop = _scrollToTop.asStateFlow() var scrolltoTopPending = false - private var lastFeedKey: String? = null + private var lastFeedKey: Any? = null override val isRefreshing: MutableState = mutableStateOf(false) + val lastNoteCreatedAtWhenFullyLoaded = MutableStateFlow(null) + fun sendToTop() { if (scrolltoTopPending) return @@ -90,6 +92,8 @@ class CardFeedContentState( private var lastAccount: Account? = null private var lastNotes: Set? = null + fun lastNoteCreatedAtIfFilled() = lastNoteCreatedAtWhenFullyLoaded.value + fun refresh() { viewModelScope.launch(Dispatchers.Default) { refreshSuspended() } } @@ -300,6 +304,13 @@ class CardFeedContentState( } private fun updateFeed(notes: ImmutableList) { + if (notes.size >= localFilter.limit()) { + val lastNomeTime = notes.lastOrNull()?.createdAt() + if (lastNomeTime != lastNoteCreatedAtWhenFullyLoaded.value) { + lastNoteCreatedAtWhenFullyLoaded.tryEmit(notes.lastOrNull()?.createdAt()) + } + } + val currentState = _feedContent.value if (notes.isEmpty()) { _feedContent.tryEmit(CardFeedState.Empty) @@ -363,8 +374,7 @@ class CardFeedContentState( bundler.invalidate(ignoreIfDoing) { // adds the time to perform the refresh into this delay // holding off new updates in case of heavy refresh routines. - val (value, elapsed) = measureTimedValue { refreshSuspended() } - Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed") + logTime("${this.javaClass.simpleName} Card update") { refreshSuspended() } } } @@ -373,12 +383,10 @@ class CardFeedContentState( bundler.invalidate(ignoreIfDoing) { // adds the time to perform the refresh into this delay // holding off new updates in case of heavy refresh routines. - val (value, elapsed) = - measureTimedValue { - refreshSuspended() - sendToTop() - } - Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed") + logTime("${this.javaClass.simpleName} Card update") { + refreshSuspended() + sendToTop() + } } } @@ -388,12 +396,10 @@ class CardFeedContentState( bundler.invalidate(false) { // adds the time to perform the refresh into this delay // holding off new updates in case of heavy refresh routines. - val (value, elapsed) = - measureTimedValue { - refreshSuspended() - sendToTop() - } - Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed") + logTime("${this.javaClass.simpleName} Card update: checkKeysInvalidateDataAndSendToTop") { + refreshSuspended() + sendToTop() + } } } } @@ -401,16 +407,11 @@ class CardFeedContentState( fun invalidateInsertData(newItems: Set) { bundlerInsert.invalidateList(newItems) { val newObjects = it.flatten().toSet() - val (value, elapsed) = - measureTimedValue { - if (newObjects.isNotEmpty()) { - refreshFromOldState(newObjects) - } + logTime("${this.javaClass.simpleName} Card additive receiving ${newObjects.size} items into ${it.size} items") { + if (newObjects.isNotEmpty()) { + refreshFromOldState(newObjects) } - Log.d( - "Time", - "${this.javaClass.simpleName} Card additive update $elapsed. ${newObjects.size}", - ) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedState.kt index 1d958fecb5..67063f9043 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedState.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt index 5065442502..45f673adf0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt @@ -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 @@ -44,6 +44,7 @@ import androidx.compose.ui.graphics.Color import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.feeds.FeedError @@ -51,7 +52,7 @@ import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.ui.feeds.rememberForeverLazyListState -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.BadgeCompose import com.vitorpamplona.amethyst.ui.note.MessageSetCompose import com.vitorpamplona.amethyst.ui.note.MultiSetCompose @@ -175,14 +176,18 @@ private fun FeedLoaded( key = { _, item -> item.id() }, contentType = { _, item -> item.javaClass.simpleName }, ) { _, item -> - Row(Modifier.fillMaxWidth().animateItemPlacement()) { - RenderCardItem( - item, - routeForLastRead, - showHidden = items.showHidden, - accountViewModel, - nav, - ) + Row(Modifier.fillMaxWidth().animateItem()) { + logTime( + debugMessage = { "CardFeedView $item" }, + ) { + RenderCardItem( + item, + routeForLastRead, + showHidden = items.showHidden, + accountViewModel, + nav, + ) + } } HorizontalDivider( thickness = DividerThickness, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt index a27d835885..2127ba8252 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt @@ -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,31 +22,27 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications import android.Manifest import android.os.Build +import androidx.annotation.RequiresApi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionState import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState -import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.ui.components.SelectNotificationProvider import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys -import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold @Composable fun NotificationScreen( @@ -75,20 +71,6 @@ fun NotificationScreen( WatchAccountForNotifications(notifFeedContentState, accountViewModel) - val lifeCycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - NostrAccountDataSource.account = accountViewModel.account - NostrAccountDataSource.invalidateFilters() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } - DisappearingScaffold( isInvertedLayout = false, topBar = { @@ -124,6 +106,7 @@ fun NotificationScreen( } } +@RequiresApi(Build.VERSION_CODES.TIRAMISU) @OptIn(ExperimentalPermissionsApi::class) @Composable fun checkifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel: SharedPreferencesViewModel): PermissionState { @@ -133,14 +116,12 @@ fun checkifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel: Sh ) if (!sharedPreferencesViewModel.sharedPrefs.dontAskForNotificationPermissions) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - if (!notificationPermissionState.status.isGranted) { - sharedPreferencesViewModel.dontAskForNotificationPermissions() + if (!notificationPermissionState.status.isGranted) { + sharedPreferencesViewModel.dontAskForNotificationPermissions() - // This will pause the APP, including the connection with relays. - LaunchedEffect(notificationPermissionState) { - notificationPermissionState.launchPermissionRequest() - } + // This will pause the APP, including the connection with relays. + LaunchedEffect(notificationPermissionState) { + notificationPermissionState.launchPermissionRequest() } } } @@ -157,8 +138,6 @@ fun WatchAccountForNotifications( accountViewModel.account.liveNotificationFollowLists.collectAsStateWithLifecycle() LaunchedEffect(accountViewModel, listState) { - NostrAccountDataSource.account = accountViewModel.account - NostrAccountDataSource.invalidateFilters() notifFeedContentState.checkKeysInvalidateDataAndSendToTop() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt index 22d33584a5..b22941856c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryView.kt index c8b4717d0d..fe9cd2a7f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryView.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationTopBar.kt index e468af880a..4724b827fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationTopBar.kt @@ -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,25 +23,47 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.ui.navigation.FollowListWithoutRoutes -import com.vitorpamplona.amethyst.ui.navigation.GenericMainTopBar -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner +import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar +import com.vitorpamplona.amethyst.ui.screen.FeedDefinition +import com.vitorpamplona.amethyst.ui.screen.FollowListState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes @Composable fun NotificationTopBar( accountViewModel: AccountViewModel, nav: INav, ) { - GenericMainTopBar(accountViewModel, nav) { + UserDrawerSearchTopBar(accountViewModel, nav) { val list by accountViewModel.account.settings.defaultNotificationFollowList .collectAsStateWithLifecycle() - FollowListWithoutRoutes( + FollowLists( followListsModel = accountViewModel.feedStates.feedListOptions, listName = list, - ) { listName -> - accountViewModel.account.settings.changeDefaultNotificationFollowList(listName.code) - } + accountViewModel = accountViewModel, + onChange = accountViewModel.account.settings::changeDefaultNotificationFollowList, + ) } } + +@Composable +private fun FollowLists( + followListsModel: FollowListState, + listName: String, + accountViewModel: AccountViewModel, + onChange: (FeedDefinition) -> Unit, +) { + val allLists by followListsModel.kind3GlobalPeople.collectAsStateWithLifecycle() + + FeedFilterSpinner( + placeholderCode = listName, + explainer = stringRes(R.string.select_list_to_filter), + options = allLists, + onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.kind3Follow) }, + accountViewModel = accountViewModel, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/AmountValueFormatter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/AmountValueFormatter.kt index c1b0a79b0e..bebdac13d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/AmountValueFormatter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/AmountValueFormatter.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/CountAxisValueFormatter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/CountAxisValueFormatter.kt index 1f64239b76..b1a4295493 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/CountAxisValueFormatter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/CountAxisValueFormatter.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/LastWeekLabelFormatter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/LastWeekLabelFormatter.kt index d9e44e0158..4f5b14473b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/LastWeekLabelFormatter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/LastWeekLabelFormatter.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/ShowChart.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/ShowChart.kt index d3a98858f0..32b64ddc9b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/ShowChart.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/ShowChart.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt similarity index 83% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index 7809871d57..bb85db4548 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -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,13 +18,18 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.experimental.forks.forkFromVersion import com.vitorpamplona.quartz.experimental.forks.isForkFromAddressWithPubkey +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent @@ -35,8 +40,9 @@ import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent @@ -60,10 +66,8 @@ class NotificationFeedFilter( fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( - userHex = account.userProfile().pubkeyHex, - selectedListName = account.settings.defaultNotificationFollowList.value, followLists = account.liveNotificationFollowLists.value, - hiddenUsers = account.flowHiddenUsers.value, + hiddenUsers = account.hiddenUsers.flow.value, ) override fun feed(): List { @@ -71,8 +75,11 @@ class NotificationFeedFilter( val notifications = LocalCache.notes.filterIntoSet { _, note -> - acceptableEvent(note, filterParams) - } + note.event !is AddressableEvent && acceptableEvent(note, filterParams) + } + + LocalCache.addressables.filterIntoSet { _, note -> + acceptableEvent(note, filterParams) + } return sort(notifications) } @@ -97,7 +104,7 @@ class NotificationFeedFilter( val zapRequest = noteEvent.zapRequest if (zapRequest != null) { if (noteEvent.zapRequest?.isPrivateZap() == true) { - zapRequest.cachedPrivateZap()?.pubKey ?: zapRequest.pubKey + account.privateZapsDecryptionCache.cachedPrivateZap(zapRequest)?.pubKey ?: zapRequest.pubKey } else { zapRequest.pubKey } @@ -105,7 +112,11 @@ class NotificationFeedFilter( noteEvent.pubKey } } else { - it.author?.pubkeyHex + if (it is AddressableNote) { + it.address.pubKeyHex + } else { + it.author?.pubkeyHex + } } return it.event !is ChannelCreateEvent && @@ -117,8 +128,9 @@ class NotificationFeedFilter( it.event !is NIP90StatusEvent && it.event !is NIP90ContentDiscoveryRequestEvent && it.event !is GiftWrapEvent && + it.event !is PrivateTagArrayEvent && (it.event is LnZapEvent || notifAuthor != loggedInUserHex) && - (filterParams.isGlobal || filterParams.followLists?.authors?.contains(notifAuthor) == true) && + (filterParams.isGlobal(it.relays) || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) && it.event?.isTaggedUser(loggedInUserHex) ?: false && (filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) && tagsAnEventByUser(it, loggedInUserHex) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt new file mode 100644 index 0000000000..f8279cc1f5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt @@ -0,0 +1,379 @@ +/** + * 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.ui.screen.loggedIn.notifications.publicMessages + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery +import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton +import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar +import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer +import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList +import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton +import com.vitorpamplona.amethyst.ui.note.creators.invoice.NewPostInvoiceRequest +import com.vitorpamplona.amethyst.ui.note.creators.location.AddGeoHashButton +import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash +import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews +import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton +import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest +import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.AddZapraiserButton +import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.ZapRaiserRequest +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapTo +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapToButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.MessageFieldRow +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.SendDirectMessageTo +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.Font14SP +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class) +@Composable +fun NewPublicMessageScreen( + to: Set? = null, + accountViewModel: AccountViewModel, + nav: Nav, +) { + val postViewModel: NewPublicMessageViewModel = viewModel() + postViewModel.init(accountViewModel) + + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + to?.let { + postViewModel.load(it) + } + } + } + + WatchAndLoadMyEmojiList(accountViewModel) + + Scaffold( + topBar = { + PostingTopBar( + titleRes = R.string.public_message, + isActive = postViewModel::canPost, + onCancel = { + // uses the accountViewModel scope to avoid cancelling this + // function when the postViewModel is released + accountViewModel.runIOCatching { + postViewModel.sendDraftSync() + delay(100) + nav.popBack() + postViewModel.cancel() + } + }, + onPost = { + // uses the accountViewModel scope to avoid cancelling this + // function when the postViewModel is released + accountViewModel.runIOCatching { + postViewModel.sendPostSync() + nav.popBack() + postViewModel.cancel() + } + nav.popBack() + }, + ) + }, + ) { pad -> + Surface( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .imePadding(), + ) { + PublicMessageScreenContent(postViewModel, accountViewModel, nav) + } + } +} + +@Composable +fun PublicMessageScreenContent( + postViewModel: NewPublicMessageViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val scrollState = rememberScrollState() + + Column(Modifier.fillMaxSize()) { + Row(Modifier.fillMaxWidth().padding(horizontal = Size10dp).weight(1f)) { + Column( + Modifier.fillMaxWidth().verticalScroll(scrollState), + verticalArrangement = spacedBy(Size10dp), + ) { + SendDirectMessageTo(postViewModel, accountViewModel) + + MessageFieldRow(postViewModel, accountViewModel) + + DisplayPreviews(postViewModel.urlPreviews, accountViewModel, nav) + + if (postViewModel.wantsToMarkAsSensitive) { + ContentSensitivityExplainer() + } + + if (postViewModel.wantsToAddGeoHash) { + LocationAsHash(postViewModel) + } + + if (postViewModel.wantsForwardZapTo) { + ForwardZapTo(postViewModel, accountViewModel) + } + + postViewModel.multiOrchestrator?.let { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + val context = LocalContext.current + ImageVideoDescription( + it, + accountViewModel.account.settings.defaultFileServer, + onAdd = { alt, server, sensitiveContent, mediaQuality -> + postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context) + if (server.type != ServerType.NIP95) { + accountViewModel.account.settings.changeDefaultFileServer(server) + } + }, + onDelete = postViewModel::deleteMediaToUpload, + onCancel = { postViewModel.multiOrchestrator = null }, + accountViewModel = accountViewModel, + ) + } + } + + if (postViewModel.wantsInvoice) { + NewPostInvoiceRequest( + onSuccess = { + postViewModel.insertAtCursor(it) + postViewModel.wantsInvoice = false + }, + accountViewModel, + ) + } + + if (postViewModel.wantsSecretEmoji) { + SecretEmojiRequest { + postViewModel.insertAtCursor(it) + postViewModel.wantsSecretEmoji = false + } + } + + if (postViewModel.wantsZapraiser && postViewModel.hasLnAddress()) { + ZapRaiserRequest( + stringRes(id = R.string.zapraiser), + postViewModel, + ) + } + } + } + + postViewModel.userSuggestions?.let { + ShowUserSuggestionList( + it, + postViewModel::autocompleteWithUser, + accountViewModel, + Modifier.heightIn(0.dp, 300.dp), + ) + } + + postViewModel.emojiSuggestions?.let { + ShowEmojiSuggestionList( + it, + postViewModel::autocompleteWithEmoji, + postViewModel::autocompleteWithEmojiUrl, + accountViewModel, + Modifier.heightIn(0.dp, 300.dp), + ) + } + + BottomRowActions(postViewModel, accountViewModel) + } +} + +@Composable +private fun BottomRowActions( + postViewModel: NewPublicMessageViewModel, + accountViewModel: AccountViewModel, +) { + val scrollState = rememberScrollState() + Row( + modifier = + Modifier + .horizontalScroll(scrollState) + .fillMaxWidth() + .height(50.dp), + verticalAlignment = CenterVertically, + ) { + SelectFromGallery( + isUploading = postViewModel.isUploadingImage, + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier, + ) { + postViewModel.selectImage(it) + } + + TakePictureButton( + onPictureTaken = { + postViewModel.selectImage(it) + }, + ) + + ForwardZapToButton(postViewModel.wantsForwardZapTo) { + postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo + } + + if (postViewModel.canAddZapRaiser) { + AddZapraiserButton(postViewModel.wantsZapraiser) { + postViewModel.wantsZapraiser = !postViewModel.wantsZapraiser + } + } + + MarkAsSensitiveButton(postViewModel.wantsToMarkAsSensitive) { + postViewModel.toggleMarkAsSensitive() + } + + AddGeoHashButton(postViewModel.wantsToAddGeoHash) { + postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash + } + + AddSecretEmojiButton(postViewModel.wantsSecretEmoji) { + postViewModel.wantsSecretEmoji = !postViewModel.wantsSecretEmoji + } + + if (postViewModel.canAddInvoice && postViewModel.hasLnAddress()) { + AddLnInvoiceButton(postViewModel.wantsInvoice) { + postViewModel.wantsInvoice = !postViewModel.wantsInvoice + } + } + } +} + +@Composable +fun SendDirectMessageTo( + postViewModel: NewPublicMessageViewModel, + accountViewModel: AccountViewModel, +) { + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + + LaunchedEffect(Unit) { + launch { + delay(200) + focusRequester.requestFocus() + } + } + + Column(Modifier.fillMaxWidth()) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringRes(R.string.messages_new_message_to), + fontSize = Font14SP, + fontWeight = FontWeight.W500, + ) + + ThinPaddingTextField( + value = postViewModel.toUsers, + onValueChange = postViewModel::updateToUsers, + modifier = + Modifier + .weight(1f) + .focusRequester(focusRequester) + .onFocusChanged { + if (it.isFocused) { + keyboardController?.show() + } + }, + placeholder = { + Text( + text = stringRes(R.string.messages_new_message_to_caption), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + visualTransformation = + UrlUserTagTransformation( + MaterialTheme.colorScheme.primary, + ), + colors = + OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = Color.Transparent, + ), + ) + } + + HorizontalDivider(thickness = DividerThickness) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt new file mode 100644 index 0000000000..948affe691 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt @@ -0,0 +1,645 @@ +/** + * 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.ui.screen.loggedIn.notifications.publicMessages + +import android.content.Context +import android.util.Log +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.compose.currentWord +import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor +import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState +import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing +import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState +import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber +import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField +import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewState +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.IZapRaiser +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.IZapField +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.SplitBuilder +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.toZapSplitSetup +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent +import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.experimental.publicMessages.tags.ReceiverTag +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.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash +import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.references.references +import com.vitorpamplona.quartz.nip10Notes.content.findHashtags +import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris +import com.vitorpamplona.quartz.nip10Notes.content.findURLs +import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes +import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji +import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag +import com.vitorpamplona.quartz.nip30CustomEmoji.emojis +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning +import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive +import com.vitorpamplona.quartz.nip37Drafts.DraftEvent +import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup +import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup +import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits +import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser +import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount +import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder +import com.vitorpamplona.quartz.nip92IMeta.imetas +import com.vitorpamplona.quartz.nip94FileMetadata.alt +import com.vitorpamplona.quartz.nip94FileMetadata.blurhash +import com.vitorpamplona.quartz.nip94FileMetadata.dims +import com.vitorpamplona.quartz.nip94FileMetadata.hash +import com.vitorpamplona.quartz.nip94FileMetadata.magnet +import com.vitorpamplona.quartz.nip94FileMetadata.mimeType +import com.vitorpamplona.quartz.nip94FileMetadata.originalHash +import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent +import com.vitorpamplona.quartz.nip94FileMetadata.size +import com.vitorpamplona.quartz.utils.Hex +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlin.collections.plus + +@Stable +class NewPublicMessageViewModel : + ViewModel(), + ILocationGrabber, + IMessageField, + IZapField, + IZapRaiser { + val draftTag = DraftTagState() + + lateinit var accountViewModel: AccountViewModel + lateinit var account: Account + + init { + viewModelScope.launch(Dispatchers.IO) { + draftTag.versions.collectLatest { + // don't save the first + if (it > 0) { + sendDraftSync() + } + } + } + } + + val iMetaAttachments = IMetaAttachments() + var nip95attachments by mutableStateOf>>(emptyList()) + + override var message by mutableStateOf(TextFieldValue("")) + + val urlPreviews = PreviewState() + + var isUploadingImage by mutableStateOf(false) + + var userSuggestions: UserSuggestionState? = null + var userSuggestionsMainMessage: UserSuggestionAnchor? = null + + var emojiSuggestions: EmojiSuggestionState? = null + + var toUsers by mutableStateOf(TextFieldValue("")) + + // Images and Videos + var multiOrchestrator by mutableStateOf(null) + + // Invoices + var canAddInvoice by mutableStateOf(false) + var wantsInvoice by mutableStateOf(false) + + var wantsSecretEmoji by mutableStateOf(false) + + // Forward Zap to + var wantsForwardZapTo by mutableStateOf(false) + override val forwardZapTo = mutableStateOf>(SplitBuilder()) + override val forwardZapToEditting = mutableStateOf(TextFieldValue("")) + + // NSFW, Sensitive + var wantsToMarkAsSensitive by mutableStateOf(false) + + // GeoHash + var wantsToAddGeoHash by mutableStateOf(false) + var location: StateFlow? = null + + // ZapRaiser + var canAddZapRaiser by mutableStateOf(false) + var wantsZapraiser by mutableStateOf(false) + override var zapRaiserAmount = mutableStateOf(null) + + fun lnAddress(): String? = account.userProfile().info?.lnAddress() + + fun hasLnAddress(): Boolean = account.userProfile().info?.lnAddress() != null + + fun user(): User? = account.userProfile() + + fun init(accountVM: AccountViewModel) { + this.accountViewModel = accountVM + this.account = accountVM.account + this.canAddInvoice = hasLnAddress() + this.canAddZapRaiser = hasLnAddress() + + this.userSuggestions?.reset() + this.userSuggestions = UserSuggestionState(accountVM) + + this.emojiSuggestions?.reset() + this.emojiSuggestions = EmojiSuggestionState(accountVM) + } + + fun load(users: Set) { + val userSet = users - account.userProfile().pubkeyHex + + toUsers = + TextFieldValue( + userSet.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, + ) + } + + fun quote(quote: Note) { + message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}") + urlPreviews.update(message) + + // creates a split with that author. + val quotedAuthor = quote.author ?: return + + if (quotedAuthor.pubkeyHex != accountViewModel.userProfile().pubkeyHex) { + if (forwardZapTo.value.items.none { it.key.pubkeyHex == quotedAuthor.pubkeyHex }) { + forwardZapTo.value.addItem(quotedAuthor) + } + if (forwardZapTo.value.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) { + forwardZapTo.value.addItem(accountViewModel.userProfile()) + } + + val pos = forwardZapTo.value.items.indexOfFirst { it.key.pubkeyHex == quotedAuthor.pubkeyHex } + forwardZapTo.value.updatePercentage(pos, 0.9f) + + wantsForwardZapTo = true + } + } + + fun editFromDraft(draft: Note) { + val noteEvent = draft.event + val noteAuthor = draft.author + + if (noteEvent is DraftEvent && noteAuthor != null) { + viewModelScope.launch(Dispatchers.IO) { + accountViewModel.createTempDraftNote(noteEvent)?.let { innerNote -> + val oldTag = (draft.event as? AddressableEvent)?.dTag() + if (oldTag != null) { + draftTag.set(oldTag) + } + loadFromDraft(innerNote) + } + } + } + } + + private fun loadFromDraft(draft: Note) { + val draftEvent = draft.event as? PublicMessageEvent ?: return + + val localForwardZapTo = draftEvent.tags.zapSplitSetup() + val totalWeight = localForwardZapTo.sumOf { it.weight } + forwardZapTo.value = SplitBuilder() + localForwardZapTo.forEach { + if (it is ZapSplitSetup) { + val user = LocalCache.getOrCreateUser(it.pubKeyHex) + forwardZapTo.value.addItem(user, (it.weight / totalWeight).toFloat()) + } + // don't support editing old-style splits. + } + forwardZapToEditting.value = TextFieldValue("") + wantsForwardZapTo = localForwardZapTo.isNotEmpty() + + wantsToMarkAsSensitive = draftEvent.isSensitive() + + val geohash = draftEvent.getGeoHash() + wantsToAddGeoHash = geohash != null + + val zapraiser = draftEvent.zapraiserAmount() + wantsZapraiser = zapraiser != null + zapRaiserAmount.value = null + if (zapraiser != null) { + zapRaiserAmount.value = zapraiser + } + + if (forwardZapTo.value.items.isNotEmpty()) { + wantsForwardZapTo = true + } + + val userSet = draftEvent.groupKeys() - account.userProfile().pubkeyHex + + toUsers = + TextFieldValue( + userSet.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, + ) + + message = TextFieldValue(draftEvent.content) + urlPreviews.update(message) + + iMetaAttachments.addAll(draftEvent.imetas()) + } + + override fun locationFlow(): StateFlow { + if (location == null) { + location = locationManager().geohashStateFlow + } + + return location!! + } + + suspend fun sendPostSync() { + val template = createTemplate() ?: return + val extraNotesToBroadcast = mutableListOf() + + if (nip95attachments.isNotEmpty()) { + val usedImages = template.tags.taggedQuoteIds().toSet() + nip95attachments.forEach { + if (usedImages.contains(it.second.id)) { + extraNotesToBroadcast.add(it.first) + extraNotesToBroadcast.add(it.second) + } + } + } + + val version = draftTag.current + cancel() + + accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast) + accountViewModel.deleteDraft(version) + } + + suspend fun sendDraftSync() { + val accountViewModel = accountViewModel + + if (message.text.isBlank()) { + accountViewModel.account.deleteDraft(draftTag.current) + } else { + val template = createTemplate() ?: return + accountViewModel.account.createAndSendDraft(draftTag.current, template) + + nip95attachments.forEach { + account.sendToPrivateOutboxAndLocal(it.first) + account.sendToPrivateOutboxAndLocal(it.second) + } + } + } + + private suspend fun createTemplate(): EventTemplate? { + val toUsersTagger = NewMessageTagger(this@NewPublicMessageViewModel.toUsers.text, null, null, accountViewModel) + toUsersTagger.run() + + val tagger = NewMessageTagger(message.text, null, null, accountViewModel) + tagger.run() + + val users = (toUsersTagger.pTags ?: emptyList()) + (tagger.pTags ?: emptyList()) + val toUsers = users.mapTo(mutableSetOf()) { ReceiverTag(it.pubkeyHex, it.bestRelayHint()) } + + val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null + + val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() + val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null + + val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value) + val urls = findURLs(tagger.message) + val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) + + val contentWarningReason = if (wantsToMarkAsSensitive) "" else null + + return PublicMessageEvent.build( + to = toUsers.toList(), + msg = tagger.message, + ) { + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + geoHash?.let { geohash(it) } + localZapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + contentWarningReason?.let { contentWarning(it) } + + emojis(emojis) + imetas(usedAttachments) + } + } + + fun findEmoji( + message: String, + myEmojiSet: List?, + ): List { + if (myEmojiSet == null) return emptyList() + return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> + myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link.url) } + } + } + + fun upload( + alt: String?, + contentWarningReason: String?, + mediaQuality: Int, + server: ServerName, + onError: (title: String, message: String) -> Unit, + context: Context, + ) = try { + uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context) + } catch (e: SignerExceptions.ReadOnlyException) { + onError( + stringRes(context, R.string.read_only_user), + stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events), + ) + } + + fun uploadUnsafe( + alt: String?, + contentWarningReason: String?, + mediaQuality: Int, + server: ServerName, + onError: (title: String, message: String) -> Unit, + context: Context, + ) { + viewModelScope.launch(Dispatchers.Default) { + val myMultiOrchestrator = multiOrchestrator ?: return@launch + + isUploadingImage = true + + val results = + myMultiOrchestrator.upload( + alt, + contentWarningReason, + MediaCompressor.intToCompressorQuality(mediaQuality), + server, + account, + context, + ) + + if (results.allGood) { + results.successful.forEach { state -> + if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) { + val nip95 = account.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, contentWarningReason) + nip95attachments = nip95attachments + nip95 + val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } + + note?.let { + message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) + urlPreviews.update(message) + } + } else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { + val iMeta = + IMetaTagBuilder(state.result.url) + .apply { + hash(state.result.fileHeader.hash) + size(state.result.fileHeader.size) + state.result.fileHeader.mimeType + ?.let { mimeType(it) } + state.result.fileHeader.dim + ?.let { dims(it) } + state.result.fileHeader.blurHash + ?.let { blurhash(it.blurhash) } + state.result.magnet?.let { magnet(it) } + state.result.uploadedHash?.let { originalHash(it) } + + alt?.let { alt(it) } + contentWarningReason?.let { sensitiveContent(contentWarningReason) } + }.build() + + iMetaAttachments.replace(iMeta.url, iMeta) + + message = message.insertUrlAtCursor(state.result.url) + urlPreviews.update(message) + } + } + + multiOrchestrator = null + } else { + val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct() + onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) + } + + isUploadingImage = false + } + } + + fun cancel() { + draftTag.rotate() + + toUsers = TextFieldValue("") + message = TextFieldValue("") + multiOrchestrator = null + + wantsInvoice = false + wantsZapraiser = false + zapRaiserAmount.value = null + + wantsForwardZapTo = false + wantsToMarkAsSensitive = false + wantsToAddGeoHash = false + wantsSecretEmoji = false + + forwardZapTo.value = SplitBuilder() + forwardZapToEditting.value = TextFieldValue("") + + urlPreviews.reset() + + userSuggestions?.reset() + userSuggestionsMainMessage = null + + iMetaAttachments.reset() + + emojiSuggestions?.reset() + } + + fun deleteMediaToUpload(selected: SelectedMediaProcessing) { + this.multiOrchestrator?.remove(selected) + } + + fun addToMessage(it: String) { + updateMessage(TextFieldValue(message.text + " " + it)) + } + + override fun updateMessage(newMessage: TextFieldValue) { + message = newMessage + urlPreviews.update(newMessage) + + if (message.selection.collapsed) { + userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE + + val lastWord = message.currentWord() + userSuggestions?.processCurrentWord(lastWord) + emojiSuggestions?.processCurrentWord(lastWord) + } + + draftTag.newVersion() + } + + fun updateToUsers(newToUsersValue: TextFieldValue) { + toUsers = newToUsersValue + + if (newToUsersValue.selection.collapsed) { + val lastWord = newToUsersValue.currentWord() + userSuggestionsMainMessage = UserSuggestionAnchor.TO_USERS + userSuggestions?.processCurrentWord(lastWord) + } + + draftTag.newVersion() + } + + override fun updateZapForwardTo(newZapForwardTo: TextFieldValue) { + forwardZapToEditting.value = newZapForwardTo + if (newZapForwardTo.selection.collapsed) { + val lastWord = newZapForwardTo.text + userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS + userSuggestions?.processCurrentWord(lastWord) + } + } + + fun autocompleteWithUser(item: User) { + userSuggestions?.let { userSuggestions -> + if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { + val lastWord = message.currentWord() + message = userSuggestions.replaceCurrentWord(message, lastWord, item) + urlPreviews.update(message) + } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { + forwardZapTo.value.addItem(item) + forwardZapToEditting.value = TextFieldValue("") + } else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) { + val lastWord = toUsers.currentWord() + toUsers = userSuggestions.replaceCurrentWord(toUsers, lastWord, item) + } + + userSuggestionsMainMessage = null + userSuggestions.reset() + } + + draftTag.newVersion() + } + + fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { + val wordToInsert = ":${item.code}:" + + message = message.replaceCurrentWord(wordToInsert) + urlPreviews.update(message) + + emojiSuggestions?.reset() + + draftTag.newVersion() + } + + fun autocompleteWithEmojiUrl(item: EmojiPackState.EmojiMedia) { + val wordToInsert = item.link.url + " " + + viewModelScope.launch(Dispatchers.IO) { + iMetaAttachments.downloadAndPrepare(item.link.url) { + Amethyst.instance.okHttpClients.getHttpClient(accountViewModel.account.privacyState.shouldUseTorForImageDownload(item.link.url)) + } + } + + message = message.replaceCurrentWord(wordToInsert) + urlPreviews.update(message) + + emojiSuggestions?.reset() + + draftTag.newVersion() + } + + fun canPost(): Boolean = + message.text.isNotBlank() && + !isUploadingImage && + !wantsInvoice && + (!wantsZapraiser || zapRaiserAmount.value != null) && + (toUsers.text.isNotBlank()) && + multiOrchestrator == null + + fun insertAtCursor(newElement: String) { + message = message.insertUrlAtCursor(newElement) + } + + fun selectImage(uris: ImmutableList) { + multiOrchestrator = MultiOrchestrator(uris) + } + + override fun onCleared() { + super.onCleared() + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + } + + override fun updateZapPercentage( + index: Int, + sliderValue: Float, + ) { + forwardZapTo.value.updatePercentage(index, sliderValue) + + draftTag.newVersion() + } + + override fun updateZapFromText() { + viewModelScope.launch(Dispatchers.Default) { + val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel) + tagger.run() + tagger.pTags?.forEach { taggedUser -> + if (!forwardZapTo.value.items.any { it.key == taggedUser }) { + forwardZapTo.value.addItem(taggedUser) + } + } + } + } + + override fun updateZapRaiserAmount(newAmount: Long?) { + zapRaiserAmount.value = newAmount + draftTag.newVersion() + } + + fun toggleMarkAsSensitive() { + wantsToMarkAsSensitive = !wantsToMarkAsSensitive + draftTag.newVersion() + } + + override fun locationManager(): LocationState = Amethyst.instance.locationManager +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt new file mode 100644 index 0000000000..87179f8f4d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt @@ -0,0 +1,82 @@ +/** + * 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.ui.screen.loggedIn.privacy + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.tor.PrivacySettingsBody +import com.vitorpamplona.amethyst.ui.tor.TorDialogViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PrivacyOptionsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val dialogViewModel = viewModel() + + LaunchedEffect(dialogViewModel, accountViewModel) { + dialogViewModel.reset( + accountViewModel.account.settings.torSettings + .toSettings(), + ) + } + + Scaffold( + topBar = { + SavingTopBar( + titleRes = R.string.privacy_options, + onCancel = { + nav.popBack() + }, + onPost = { + accountViewModel.setTorSettings(dialogViewModel.save()) + nav.popBack() + }, + ) + }, + ) { + Column( + Modifier + .padding(it) + .fillMaxSize() + .verticalScroll( + rememberScrollState(), + ).padding(horizontal = 10.dp), + ) { + PrivacySettingsBody(dialogViewModel) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/FollowButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/FollowButtons.kt index 4416a934c0..ad9f66381c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/FollowButtons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/FollowButtons.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt index 1335d383db..f62ec2ac3d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt @@ -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 @@ -41,10 +41,8 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -57,48 +55,46 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.map import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.observeAccountIsHiddenUser +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.bookmarks.BookmarkTabHeader -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.bookmarks.NostrUserProfileBookmarksFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.bookmarks.TabBookmarks -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.conversations.NostrUserProfileConversationsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.bookmarks.dal.UserProfileBookmarksFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.conversations.TabNotesConversations +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.conversations.dal.UserProfileConversationsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource.UserProfileFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.followers.FollowersTabHeader -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.followers.NostrUserProfileFollowersUserFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.followers.TabFollowers +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.followers.dal.UserProfileFollowersUserFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.follows.FollowTabHeader -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.follows.NostrUserProfileFollowsUserFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.follows.TabFollows -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.NostrUserProfileGalleryFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.follows.dal.UserProfileFollowsUserFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.TabGallery +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.dal.UserProfileGalleryFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.hashtags.FollowedTagsTabHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.hashtags.TabFollowedTags import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.ProfileHeader -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.NostrUserAppRecommendationsFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual.NostrUserProfileMutualFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.UserAppRecommendationsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual.TabMutualConversations -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.NostrUserProfileNewThreadsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual.dal.UserProfileMutualFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.TabNotesNewThreads +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.dal.UserProfileNewThreadsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.relays.RelaysTabHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.relays.TabRelays -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.NostrUserProfileReportFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.ReportsTabHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.TabReports -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.NostrUserProfileZapsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal.UserProfileReportFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.TabReceivedZaps import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.ZapTabHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.dal.UserProfileZapsFeedViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import kotlinx.coroutines.Dispatchers @@ -141,99 +137,96 @@ fun PrepareViewModels( accountViewModel: AccountViewModel, nav: INav, ) { - val followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel = + val followsFeedViewModel: UserProfileFollowsUserFeedViewModel = viewModel( key = baseUser.pubkeyHex + "UserProfileFollowsUserFeedViewModel", factory = - NostrUserProfileFollowsUserFeedViewModel.Factory( + UserProfileFollowsUserFeedViewModel.Factory( baseUser, accountViewModel.account, ), ) - val galleryFeedViewModel: NostrUserProfileGalleryFeedViewModel = + val galleryFeedViewModel: UserProfileGalleryFeedViewModel = viewModel( key = baseUser.pubkeyHex + "UserGalleryFeedViewModel", factory = - NostrUserProfileGalleryFeedViewModel.Factory( + UserProfileGalleryFeedViewModel.Factory( baseUser, accountViewModel.account, ), ) - val followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel = + val followersFeedViewModel: UserProfileFollowersUserFeedViewModel = viewModel( key = baseUser.pubkeyHex + "UserProfileFollowersUserFeedViewModel", factory = - NostrUserProfileFollowersUserFeedViewModel.Factory( + UserProfileFollowersUserFeedViewModel.Factory( baseUser, accountViewModel.account, ), ) - val appRecommendations: NostrUserAppRecommendationsFeedViewModel = + val appRecommendations: UserAppRecommendationsFeedViewModel = viewModel( key = baseUser.pubkeyHex + "UserAppRecommendationsFeedViewModel", - factory = - NostrUserAppRecommendationsFeedViewModel.Factory( - baseUser, - ), + factory = UserAppRecommendationsFeedViewModel.Factory(baseUser), ) - val zapFeedViewModel: NostrUserProfileZapsFeedViewModel = + val zapFeedViewModel: UserProfileZapsFeedViewModel = viewModel( key = baseUser.pubkeyHex + "UserProfileZapsFeedViewModel", factory = - NostrUserProfileZapsFeedViewModel.Factory( + UserProfileZapsFeedViewModel.Factory( baseUser, ), ) - val threadsViewModel: NostrUserProfileNewThreadsFeedViewModel = + val threadsViewModel: UserProfileNewThreadsFeedViewModel = viewModel( key = baseUser.pubkeyHex + "UserProfileNewThreadsFeedViewModel", factory = - NostrUserProfileNewThreadsFeedViewModel.Factory( + UserProfileNewThreadsFeedViewModel.Factory( baseUser, accountViewModel.account, ), ) - val repliesViewModel: NostrUserProfileConversationsFeedViewModel = + val repliesViewModel: UserProfileConversationsFeedViewModel = viewModel( key = baseUser.pubkeyHex + "UserProfileConversationsFeedViewModel", factory = - NostrUserProfileConversationsFeedViewModel.Factory( + UserProfileConversationsFeedViewModel.Factory( baseUser, accountViewModel.account, ), ) - val mutualViewModel: NostrUserProfileMutualFeedViewModel = + val mutualViewModel: UserProfileMutualFeedViewModel = viewModel( key = baseUser.pubkeyHex + "UserProfileMutualFeedViewModel", factory = - NostrUserProfileMutualFeedViewModel.Factory( + UserProfileMutualFeedViewModel.Factory( baseUser, accountViewModel.account, ), ) - val bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel = + val bookmarksFeedViewModel: UserProfileBookmarksFeedViewModel = viewModel( key = baseUser.pubkeyHex + "UserProfileBookmarksFeedViewModel", factory = - NostrUserProfileBookmarksFeedViewModel.Factory( + UserProfileBookmarksFeedViewModel.Factory( baseUser, accountViewModel.account, ), ) - val reportsFeedViewModel: NostrUserProfileReportFeedViewModel = + val reportsFeedViewModel: UserProfileReportFeedViewModel = viewModel( key = baseUser.pubkeyHex + "UserProfileReportFeedViewModel", factory = - NostrUserProfileReportFeedViewModel.Factory( + UserProfileReportFeedViewModel.Factory( baseUser, ), ) @@ -258,49 +251,30 @@ fun PrepareViewModels( @Composable fun ProfileScreen( baseUser: User, - threadsViewModel: NostrUserProfileNewThreadsFeedViewModel, - repliesViewModel: NostrUserProfileConversationsFeedViewModel, - mutualViewModel: NostrUserProfileMutualFeedViewModel, - followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel, - followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, - appRecommendations: NostrUserAppRecommendationsFeedViewModel, - zapFeedViewModel: NostrUserProfileZapsFeedViewModel, - bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel, - galleryFeedViewModel: NostrUserProfileGalleryFeedViewModel, - reportsFeedViewModel: NostrUserProfileReportFeedViewModel, + threadsViewModel: UserProfileNewThreadsFeedViewModel, + repliesViewModel: UserProfileConversationsFeedViewModel, + mutualViewModel: UserProfileMutualFeedViewModel, + followsFeedViewModel: UserProfileFollowsUserFeedViewModel, + followersFeedViewModel: UserProfileFollowersUserFeedViewModel, + appRecommendations: UserAppRecommendationsFeedViewModel, + zapFeedViewModel: UserProfileZapsFeedViewModel, + bookmarksFeedViewModel: UserProfileBookmarksFeedViewModel, + galleryFeedViewModel: UserProfileGalleryFeedViewModel, + reportsFeedViewModel: UserProfileReportFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { - NostrUserProfileDataSource.loadUserProfile(baseUser) + WatchLifecycleAndUpdateModel(threadsViewModel) + WatchLifecycleAndUpdateModel(repliesViewModel) + WatchLifecycleAndUpdateModel(mutualViewModel) + WatchLifecycleAndUpdateModel(followsFeedViewModel) + WatchLifecycleAndUpdateModel(followersFeedViewModel) + WatchLifecycleAndUpdateModel(appRecommendations) + WatchLifecycleAndUpdateModel(bookmarksFeedViewModel) + WatchLifecycleAndUpdateModel(galleryFeedViewModel) + WatchLifecycleAndUpdateModel(reportsFeedViewModel) - val lifeCycleOwner = LocalLifecycleOwner.current - - DisposableEffect(accountViewModel) { - NostrUserProfileDataSource.start() - onDispose { - NostrUserProfileDataSource.loadUserProfile(null) - NostrUserProfileDataSource.stop() - } - } - - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Profidle Start") - NostrUserProfileDataSource.loadUserProfile(baseUser) - NostrUserProfileDataSource.start() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Profile Stop") - NostrUserProfileDataSource.loadUserProfile(null) - NostrUserProfileDataSource.stop() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } + UserProfileFilterAssemblerSubscription(baseUser, accountViewModel.dataSources().profile) RenderSurface { tabRowModifier: Modifier, pagerModifier: Modifier -> RenderScreen( @@ -400,16 +374,16 @@ private fun RenderScreen( baseUser: User, tabRowModifier: Modifier, pagerModifier: Modifier, - threadsViewModel: NostrUserProfileNewThreadsFeedViewModel, - repliesViewModel: NostrUserProfileConversationsFeedViewModel, - mutualViewModel: NostrUserProfileMutualFeedViewModel, - appRecommendations: NostrUserAppRecommendationsFeedViewModel, - followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel, - followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, - zapFeedViewModel: NostrUserProfileZapsFeedViewModel, - bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel, - galleryFeedViewModel: NostrUserProfileGalleryFeedViewModel, - reportsFeedViewModel: NostrUserProfileReportFeedViewModel, + threadsViewModel: UserProfileNewThreadsFeedViewModel, + repliesViewModel: UserProfileConversationsFeedViewModel, + mutualViewModel: UserProfileMutualFeedViewModel, + appRecommendations: UserAppRecommendationsFeedViewModel, + followsFeedViewModel: UserProfileFollowsUserFeedViewModel, + followersFeedViewModel: UserProfileFollowersUserFeedViewModel, + zapFeedViewModel: UserProfileZapsFeedViewModel, + bookmarksFeedViewModel: UserProfileBookmarksFeedViewModel, + galleryFeedViewModel: UserProfileGalleryFeedViewModel, + reportsFeedViewModel: UserProfileReportFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { @@ -425,7 +399,11 @@ private fun RenderScreen( modifier = tabRowModifier, divider = { HorizontalDivider(thickness = DividerThickness) }, ) { - CreateAndRenderTabs(baseUser, pagerState) + CreateAndRenderTabs( + baseUser, + pagerState, + accountViewModel, + ) } HorizontalPager( state = pagerState, @@ -454,15 +432,15 @@ private fun RenderScreen( private fun CreateAndRenderPages( page: Int, baseUser: User, - threadsViewModel: NostrUserProfileNewThreadsFeedViewModel, - repliesViewModel: NostrUserProfileConversationsFeedViewModel, - mutualViewModel: NostrUserProfileMutualFeedViewModel, - followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel, - followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, - zapFeedViewModel: NostrUserProfileZapsFeedViewModel, - bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel, - galleryFeedViewModel: NostrUserProfileGalleryFeedViewModel, - reportsFeedViewModel: NostrUserProfileReportFeedViewModel, + threadsViewModel: UserProfileNewThreadsFeedViewModel, + repliesViewModel: UserProfileConversationsFeedViewModel, + mutualViewModel: UserProfileMutualFeedViewModel, + followsFeedViewModel: UserProfileFollowsUserFeedViewModel, + followersFeedViewModel: UserProfileFollowersUserFeedViewModel, + zapFeedViewModel: UserProfileZapsFeedViewModel, + bookmarksFeedViewModel: UserProfileBookmarksFeedViewModel, + galleryFeedViewModel: UserProfileGalleryFeedViewModel, + reportsFeedViewModel: UserProfileReportFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { @@ -491,15 +469,11 @@ private fun CreateAndRenderPages( @Composable fun UpdateThreadsAndRepliesWhenBlockUnblock( baseUser: User, - threadsViewModel: NostrUserProfileNewThreadsFeedViewModel, - repliesViewModel: NostrUserProfileConversationsFeedViewModel, + threadsViewModel: UserProfileNewThreadsFeedViewModel, + repliesViewModel: UserProfileConversationsFeedViewModel, accountViewModel: AccountViewModel, ) { - val isHidden by - accountViewModel.account.liveHiddenUsers - .map { - it.hiddenUsers.contains(baseUser.pubkeyHex) || it.spammers.contains(baseUser.pubkeyHex) - }.observeAsState(accountViewModel.account.isHidden(baseUser)) + val isHidden by observeAccountIsHiddenUser(accountViewModel.account, baseUser) LaunchedEffect(key1 = isHidden) { threadsViewModel.invalidateData() @@ -511,6 +485,7 @@ fun UpdateThreadsAndRepliesWhenBlockUnblock( private fun CreateAndRenderTabs( baseUser: User, pagerState: PagerState, + accountViewModel: AccountViewModel, ) { val coroutineScope = rememberCoroutineScope() @@ -520,13 +495,13 @@ private fun CreateAndRenderTabs( { Text(text = stringRes(R.string.replies)) }, { Text(text = stringRes(R.string.mutual)) }, { Text(text = stringRes(R.string.gallery)) }, - { FollowTabHeader(baseUser) }, - { FollowersTabHeader(baseUser) }, - { ZapTabHeader(baseUser) }, - { BookmarkTabHeader(baseUser) }, - { FollowedTagsTabHeader(baseUser) }, - { ReportsTabHeader(baseUser) }, - { RelaysTabHeader(baseUser) }, + { FollowTabHeader(baseUser, accountViewModel) }, + { FollowersTabHeader(baseUser, accountViewModel) }, + { ZapTabHeader(baseUser, accountViewModel) }, + { BookmarkTabHeader(baseUser, accountViewModel) }, + { FollowedTagsTabHeader(baseUser, accountViewModel) }, + { ReportsTabHeader(baseUser, accountViewModel) }, + { RelaysTabHeader(baseUser, accountViewModel) }, ) tabs.forEachIndexed { index, function -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/BookmarkTabHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/BookmarkTabHeader.kt index bbb4f51e9d..730d1dc3eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/BookmarkTabHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/BookmarkTabHeader.kt @@ -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,33 +22,19 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.bookmarks import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserBookmarkCount +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch @Composable -fun BookmarkTabHeader(baseUser: User) { - val userState by baseUser.live().bookmarks.observeAsState() - - var userBookmarks by remember { mutableIntStateOf(0) } - - LaunchedEffect(key1 = userState) { - launch(Dispatchers.IO) { - val newBookmarks = userState?.user?.latestBookmarkList?.countBookmarks() ?: 0 - - if (newBookmarks != userBookmarks) { - userBookmarks = newBookmarks - } - } - } +fun BookmarkTabHeader( + baseUser: User, + accountViewModel: AccountViewModel, +) { + val userBookmarks by observeUserBookmarkCount(baseUser, accountViewModel) Text(text = "$userBookmarks ${stringRes(R.string.bookmarks)}") } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/TabBookmarks.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/TabBookmarks.kt index 5a43e0eff4..73f09154d2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/TabBookmarks.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/TabBookmarks.kt @@ -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,13 +27,14 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.bookmarks.dal.UserProfileBookmarksFeedViewModel @Composable fun TabBookmarks( - feedViewModel: NostrUserProfileBookmarksFeedViewModel, + feedViewModel: UserProfileBookmarksFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileBookmarksFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/dal/UserProfileBookmarksFeedFilter.kt similarity index 60% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileBookmarksFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/dal/UserProfileBookmarksFeedFilter.kt index 7270b22f38..dee62a42af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileBookmarksFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/dal/UserProfileBookmarksFeedFilter.kt @@ -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,14 +18,17 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.bookmarks.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.LocalCache.notes import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses -import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents +import com.vitorpamplona.amethyst.ui.dal.FeedFilter +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark class UserProfileBookmarksFeedFilter( val user: User, @@ -34,22 +37,19 @@ class UserProfileBookmarksFeedFilter( override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + user.pubkeyHex override fun feed(): List { + val note = LocalCache.getOrCreateAddressableNote(BookmarkListEvent.createBookmarkAddress(user.pubkeyHex)) + val noteEvent = note.event as? BookmarkListEvent + + if (noteEvent == null) return emptyList() + val notes = - user.latestBookmarkList - ?.taggedEvents() - ?.mapNotNull { LocalCache.checkGetOrCreateNote(it) } - ?.toSet() - ?: emptySet() + noteEvent.publicBookmarks().mapNotNull { + when (it) { + is AddressBookmark -> LocalCache.getOrCreateAddressableNote(it.address) + is EventBookmark -> LocalCache.checkGetOrCreateNote(it.eventId) + } + } - val addresses = - user.latestBookmarkList - ?.taggedAddresses() - ?.map { LocalCache.getOrCreateAddressableNote(it) } - ?.toSet() - ?: emptySet() - - return (notes + addresses) - .filter { account.isAcceptable(it) } - .sortedWith(DefaultFeedOrder) + return notes.reversed() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/NostrUserProfileBookmarksFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/dal/UserProfileBookmarksFeedViewModel.kt similarity index 77% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/NostrUserProfileBookmarksFeedViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/dal/UserProfileBookmarksFeedViewModel.kt index e88e526638..8a84b7086a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/NostrUserProfileBookmarksFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/dal/UserProfileBookmarksFeedViewModel.kt @@ -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,16 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.bookmarks +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.bookmarks.dal import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter import com.vitorpamplona.amethyst.ui.screen.FeedViewModel -class NostrUserProfileBookmarksFeedViewModel( +class UserProfileBookmarksFeedViewModel( val user: User, val account: Account, ) : FeedViewModel(UserProfileBookmarksFeedFilter(user, account)) { @@ -35,8 +34,7 @@ class NostrUserProfileBookmarksFeedViewModel( val user: User, val account: Account, ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrUserProfileBookmarksFeedViewModel = - NostrUserProfileBookmarksFeedViewModel(user, account) - as NostrUserProfileBookmarksFeedViewModel + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = UserProfileBookmarksFeedViewModel(user, account) as T } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/TabNotesConversations.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/TabNotesConversations.kt index 1594158400..e5c89e3c03 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/TabNotesConversations.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/TabNotesConversations.kt @@ -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,13 +24,14 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.conversations.dal.UserProfileConversationsFeedViewModel @Composable fun TabNotesConversations( - feedViewModel: NostrUserProfileConversationsFeedViewModel, + feedViewModel: UserProfileConversationsFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt index 66ca7fd2fc..8cfa16a104 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt @@ -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,18 +18,21 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.conversations.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent class UserProfileConversationsFeedFilter( val user: User, @@ -63,6 +66,7 @@ class UserProfileConversationsFeedFilter( it.event is ChannelMessageEvent || it.event is LiveActivitiesChatMessageEvent || it.event is CommentEvent || + it.event is VoiceReplyEvent || it.event is TorrentCommentEvent ) && !it.isNewThread() && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/NostrUserProfileConversationsFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedViewModel.kt similarity index 76% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/NostrUserProfileConversationsFeedViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedViewModel.kt index 0e27d9fefe..3b276e1a7b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/NostrUserProfileConversationsFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedViewModel.kt @@ -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,16 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.conversations +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.conversations.dal import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter import com.vitorpamplona.amethyst.ui.screen.FeedViewModel -class NostrUserProfileConversationsFeedViewModel( +class UserProfileConversationsFeedViewModel( val user: User, val account: Account, ) : FeedViewModel(UserProfileConversationsFeedFilter(user, account)) { @@ -35,8 +34,7 @@ class NostrUserProfileConversationsFeedViewModel( val user: User, val account: Account, ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrUserProfileConversationsFeedViewModel = - NostrUserProfileConversationsFeedViewModel(user, account) - as NostrUserProfileConversationsFeedViewModel + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = UserProfileConversationsFeedViewModel(user, account) as T } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileFollowers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileFollowers.kt new file mode 100644 index 0000000000..274fc334bc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileFollowers.kt @@ -0,0 +1,45 @@ +/** + * 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.ui.screen.loggedIn.profile.datasource + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent + +val UserProfileFollowersKinds = listOf(ContactListEvent.KIND) + +fun filterUserProfileFollowers( + user: User, + since: SincePerRelayMap?, +): List = + user.inboxRelays().map { + RelayBasedFilter( + relay = it, + filter = + Filter( + kinds = UserProfileFollowersKinds, + tags = mapOf("p" to listOf(user.pubkeyHex)), + since = since?.get(it)?.time, + ), + ) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt new file mode 100644 index 0000000000..948c3fa135 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource + +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent + +val UserProfileListKinds = + listOf( + BookmarkListEvent.KIND, + PeopleListEvent.KIND, + FollowListEvent.KIND, + HashtagListEvent.KIND, + AppRecommendationEvent.KIND, + ) + +fun filterUserProfileLists( + users: Map>, + since: SincePerRelayMap?, +): List = + users.map { + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = UserProfileListKinds, + authors = it.value.sorted(), + limit = 100, + since = since?.get(it.key)?.time, + ), + ) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt new file mode 100644 index 0000000000..a1a97d3320 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt @@ -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.ui.screen.loggedIn.profile.datasource + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent + +val UserProfileMediaKinds = + listOf( + PictureEvent.KIND, + ProfileGalleryEntryEvent.KIND, + VideoVerticalEvent.KIND, + VideoHorizontalEvent.KIND, + ) + +fun filterUserProfileMedia( + user: User, + since: SincePerRelayMap?, +): List = + user.outboxRelays().map { relay -> + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = UserProfileMediaKinds, + authors = listOf(user.pubkeyHex), + limit = 200, + since = since?.get(relay)?.time, + ), + ) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMetadata.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMetadata.kt new file mode 100644 index 0000000000..696dba77b5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMetadata.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource + +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent + +val UserProfileMetadataKinds = + listOf( + MetadataEvent.KIND, + AdvertisedRelayListEvent.KIND, + ContactListEvent.KIND, + BadgeProfilesEvent.KIND, + ) + +fun filterUserProfileMetadata( + users: Map>, + since: SincePerRelayMap?, +): List { + if (users.isEmpty()) return emptyList() + + return users.map { + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = UserProfileMetadataKinds, + authors = it.value.sorted(), + since = since?.get(it.key)?.time, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt new file mode 100644 index 0000000000..05f1534b0e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt @@ -0,0 +1,93 @@ +/** + * 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.ui.screen.loggedIn.profile.datasource + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent +import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent +import com.vitorpamplona.quartz.nip51Lists.PinListEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent + +val UserProfilePostKinds1 = + listOf( + TextNoteEvent.KIND, + GenericRepostEvent.KIND, + RepostEvent.KIND, + LongTextNoteEvent.KIND, + PinListEvent.KIND, + PollNoteEvent.KIND, + HighlightEvent.KIND, + WikiNoteEvent.KIND, + VoiceEvent.KIND, + ) + +val UserProfilePostKinds2 = + listOf( + TorrentEvent.KIND, + TorrentCommentEvent.KIND, + InteractiveStoryPrologueEvent.KIND, + CommentEvent.KIND, + VoiceReplyEvent.KIND, + ) + +fun filterUserProfilePosts( + user: User, + since: SincePerRelayMap?, +): List = + user + .outboxRelays() + .map { relay -> + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = UserProfilePostKinds1, + authors = listOf(user.pubkeyHex), + limit = 200, + since = since?.get(relay)?.time, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = UserProfilePostKinds2, + authors = listOf(user.pubkeyHex), + limit = 50, + since = since?.get(relay)?.time, + ), + ), + ) + }.flatten() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt new file mode 100644 index 0000000000..ffd9635425 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt @@ -0,0 +1,46 @@ +/** + * 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.ui.screen.loggedIn.profile.datasource + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent + +val UserProfileZapReceiverKinds = listOf(LnZapEvent.KIND) + +fun filterUserProfileZapsReceived( + user: User, + since: SincePerRelayMap?, +): List = + user.inboxRelays().map { relay -> + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = UserProfileZapReceiverKinds, + tags = mapOf("p" to listOf(user.pubkeyHex)), + limit = 200, + since = since?.get(relay)?.time, + ), + ) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssembler.kt new file mode 100644 index 0000000000..f2a5a1d031 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssembler.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient + +// This allows multiple screen to be listening to tags, even the same tag +class UserProfileQueryState( + val user: User, +) + +class UserProfileFilterAssembler( + client: NostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + // 5 subs per visible user profile screen. + UserProfileMetadataFilterSubAssembler(client, ::allKeys), + UserProfilePostsFilterSubAssembler(client, ::allKeys), + UserProfileMediaFilterSubAssembler(client, ::allKeys), + UserProfileFollowersFilterSubAssembler(client, ::allKeys), + UserProfileZapsFilterSubAssembler(client, ::allKeys), + ) + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun invalidateKeys() = invalidateFilters() + + override fun destroy() = group.forEach { it.destroy() } + + override fun printStats() = group.forEach { it.printStats() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..94af9e02b7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssemblerSubscription.kt @@ -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.ui.screen.loggedIn.profile.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription + +@Composable +fun UserProfileFilterAssemblerSubscription( + user: User, + assembler: UserProfileFilterAssembler, +) { + // different screens get different states + // even if they are tracking the same tag. + val state = + remember(user) { + UserProfileQueryState(user) + } + + KeyDataSourceSubscription(state, assembler) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFollowersFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFollowersFilterSubAssembler.kt new file mode 100644 index 0000000000..dbfd219712 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFollowersFilterSubAssembler.kt @@ -0,0 +1,38 @@ +/** + * 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.ui.screen.loggedIn.profile.datasource + +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +class UserProfileFollowersFilterSubAssembler( + client: NostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun updateFilter( + key: UserProfileQueryState, + since: SincePerRelayMap?, + ): List? = filterUserProfileFollowers(user(key), since) + + override fun user(key: UserProfileQueryState) = key.user +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMediaFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMediaFilterSubAssembler.kt new file mode 100644 index 0000000000..40fadd1a4d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMediaFilterSubAssembler.kt @@ -0,0 +1,38 @@ +/** + * 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.ui.screen.loggedIn.profile.datasource + +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +class UserProfileMediaFilterSubAssembler( + client: NostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun updateFilter( + key: UserProfileQueryState, + since: SincePerRelayMap?, + ): List = filterUserProfileMedia(user(key), since) + + override fun user(key: UserProfileQueryState) = key.user +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMetadataFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMetadataFilterSubAssembler.kt new file mode 100644 index 0000000000..0b028e7410 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMetadataFilterSubAssembler.kt @@ -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.ui.screen.loggedIn.profile.datasource + +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.utils.mapOfSet + +class UserProfileMetadataFilterSubAssembler( + client: NostrClient, + allKeys: () -> Set, +) : SingleSubEoseManager(client, allKeys) { + override fun updateFilter( + keys: List, + since: SincePerRelayMap?, + ): List? { + val userPerRelay = + mapOfSet { + keys.mapTo(mutableSetOf()) { key -> key.user }.forEach { user -> + user.outboxRelays().forEach { relay -> + add(relay, user.pubkeyHex) + } + } + } + + return listOfNotNull( + filterUserProfileMetadata(userPerRelay, since), + filterUserProfileLists(userPerRelay, since), + ).flatten() + } + + override fun distinct(key: UserProfileQueryState) = key.user +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfilePostsFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfilePostsFilterSubAssembler.kt new file mode 100644 index 0000000000..a4223f5a69 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfilePostsFilterSubAssembler.kt @@ -0,0 +1,38 @@ +/** + * 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.ui.screen.loggedIn.profile.datasource + +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +class UserProfilePostsFilterSubAssembler( + client: NostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun updateFilter( + key: UserProfileQueryState, + since: SincePerRelayMap?, + ): List = filterUserProfilePosts(user(key), since) + + override fun user(key: UserProfileQueryState) = key.user +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileZapsFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileZapsFilterSubAssembler.kt new file mode 100644 index 0000000000..04808d7247 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileZapsFilterSubAssembler.kt @@ -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.ui.screen.loggedIn.profile.datasource + +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +class UserProfileZapsFilterSubAssembler( + client: NostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun updateFilter( + key: UserProfileQueryState, + since: SincePerRelayMap?, + ): List? = + listOfNotNull( + filterUserProfileZapsReceived(user(key), since), + ).flatten() + + override fun user(key: UserProfileQueryState) = key.user +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/FollowersTabHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/FollowersTabHeader.kt index 0825762231..0d132a4825 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/FollowersTabHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/FollowersTabHeader.kt @@ -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,34 +22,26 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.followers import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserFollowerCount +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch @Composable -fun FollowersTabHeader(baseUser: User) { - val userState by baseUser.live().followers.observeAsState() - var followerCount by remember { mutableStateOf("--") } +fun FollowersTabHeader( + baseUser: User, + accountViewModel: AccountViewModel, +) { + val followerCount by observeUserFollowerCount(baseUser, accountViewModel) - val text = stringRes(R.string.followers) - - LaunchedEffect(key1 = userState) { - launch(Dispatchers.IO) { - val newFollower = (userState?.user?.transientFollowerCount()?.toString() ?: "--") + " " + text - - if (followerCount != newFollower) { - followerCount = newFollower - } + val text = + if (followerCount > 0) { + stringRes(R.string.number_followers, followerCount) + } else { + stringRes(R.string.number_followers, "--") } - } - Text(text = followerCount) + Text(text = text) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/TabFollowers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/TabFollowers.kt index 2d94257893..d242b96256 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/TabFollowers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/TabFollowers.kt @@ -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,10 +25,10 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserFollowers +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -40,7 +40,7 @@ fun TabFollowers( accountViewModel: AccountViewModel, nav: INav, ) { - WatchFollowerChanges(baseUser, feedViewModel) + WatchFollowerChanges(baseUser, feedViewModel, accountViewModel) Column(Modifier.fillMaxHeight()) { RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav, enablePullRefresh = false) @@ -51,8 +51,9 @@ fun TabFollowers( private fun WatchFollowerChanges( baseUser: User, feedViewModel: UserFeedViewModel, + accountViewModel: AccountViewModel, ) { - val userState by baseUser.live().followers.observeAsState() + val userState by observeUserFollowers(baseUser, accountViewModel) LaunchedEffect(userState) { feedViewModel.invalidateData() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowersFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersFeedFilter.kt similarity index 90% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowersFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersFeedFilter.kt index 29165cb065..4d81c18ca8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowersFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersFeedFilter.kt @@ -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,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.followers.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.FeedFilter class UserProfileFollowersFeedFilter( val user: User, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/NostrUserProfileFollowersUserFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersUserFeedViewModel.kt similarity index 76% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/NostrUserProfileFollowersUserFeedViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersUserFeedViewModel.kt index 2660475ed8..674421db30 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/NostrUserProfileFollowersUserFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersUserFeedViewModel.kt @@ -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,16 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.followers +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.followers.dal import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowersFeedFilter import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel -class NostrUserProfileFollowersUserFeedViewModel( +class UserProfileFollowersUserFeedViewModel( val user: User, val account: Account, ) : UserFeedViewModel(UserProfileFollowersFeedFilter(user, account)) { @@ -35,8 +34,7 @@ class NostrUserProfileFollowersUserFeedViewModel( val user: User, val account: Account, ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrUserProfileFollowersUserFeedViewModel = - NostrUserProfileFollowersUserFeedViewModel(user, account) - as NostrUserProfileFollowersUserFeedViewModel + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = UserProfileFollowersUserFeedViewModel(user, account) as T } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/FollowTabHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/FollowTabHeader.kt index c1c324ffac..58e0855178 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/FollowTabHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/FollowTabHeader.kt @@ -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,34 +22,26 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.follows import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserFollowCount +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch @Composable -fun FollowTabHeader(baseUser: User) { - val userState by baseUser.live().follows.observeAsState() - var followCount by remember { mutableStateOf("--") } +fun FollowTabHeader( + baseUser: User, + accountViewModel: AccountViewModel, +) { + val followCount by observeUserFollowCount(baseUser, accountViewModel) - val text = stringRes(R.string.follows) - - LaunchedEffect(key1 = userState) { - launch(Dispatchers.IO) { - val newFollow = (userState?.user?.transientFollowCount()?.toString() ?: "--") + " " + text - - if (followCount != newFollow) { - followCount = newFollow - } + val text = + if (followCount > 0) { + stringRes(R.string.number_following, followCount) + } else { + stringRes(R.string.number_following, "--") } - } - Text(text = followCount) + Text(text = text) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/TabFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/TabFollows.kt index 77a059efe0..aaa112059d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/TabFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/TabFollows.kt @@ -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,10 +25,10 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserFollows +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -40,7 +40,7 @@ fun TabFollows( accountViewModel: AccountViewModel, nav: INav, ) { - WatchFollowChanges(baseUser, feedViewModel) + WatchFollowChanges(baseUser, feedViewModel, accountViewModel) Column(Modifier.fillMaxHeight()) { RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav, enablePullRefresh = false) @@ -51,8 +51,9 @@ fun TabFollows( private fun WatchFollowChanges( baseUser: User, feedViewModel: UserFeedViewModel, + accountViewModel: AccountViewModel, ) { - val userState by baseUser.live().follows.observeAsState() + val userState by observeUserFollows(baseUser, accountViewModel) LaunchedEffect(userState) { feedViewModel.invalidateData() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/dal/UserProfileFollowsFeedFilter.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowsFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/dal/UserProfileFollowsFeedFilter.kt index f6b9c0ff0c..0208a8a55a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/dal/UserProfileFollowsFeedFilter.kt @@ -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,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.follows.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent class UserProfileFollowsFeedFilter( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/NostrUserProfileFollowsUserFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/dal/UserProfileFollowsUserFeedViewModel.kt similarity index 77% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/NostrUserProfileFollowsUserFeedViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/dal/UserProfileFollowsUserFeedViewModel.kt index 2776ad572c..ea4e65f2a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/NostrUserProfileFollowsUserFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/dal/UserProfileFollowsUserFeedViewModel.kt @@ -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,16 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.follows +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.follows.dal import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowsFeedFilter import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel -class NostrUserProfileFollowsUserFeedViewModel( +class UserProfileFollowsUserFeedViewModel( val user: User, val account: Account, ) : UserFeedViewModel(UserProfileFollowsFeedFilter(user, account)) { @@ -35,8 +34,7 @@ class NostrUserProfileFollowsUserFeedViewModel( val user: User, val account: Account, ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrUserProfileFollowsUserFeedViewModel = - NostrUserProfileFollowsUserFeedViewModel(user, account) - as NostrUserProfileFollowsUserFeedViewModel + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = UserProfileFollowsUserFeedViewModel(user, account) as T } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryCardCompose.kt index d815efebdc..aeb22a742e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryCardCompose.kt @@ -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,15 +24,14 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.SensitivityWarning -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.CheckHiddenFeedWatchBlockAndReport import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -121,7 +120,7 @@ fun RedirectableGalleryCard( ) { if (sourceNote != galleryNote) { // preloads target note - val loadedSourceEvent by sourceNote.live().hasEvent.observeAsState(sourceNote.event != null) + EventFinderFilterAssemblerSubscription(sourceNote, accountViewModel) } SensitivityWarning( @@ -155,7 +154,7 @@ fun ClickableNote( } else { baseNote } - routeFor(redirectToNote, accountViewModel.userProfile())?.let { nav.nav(it) } + routeFor(redirectToNote, accountViewModel.account)?.let { nav.nav(it) } }, onLongClick = showPopup, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt index efbc5f58fe..6c68988524 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt @@ -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 @@ -31,7 +31,6 @@ import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -53,6 +52,7 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isVi import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid import com.vitorpamplona.amethyst.ui.components.ClickableUrl @@ -60,7 +60,7 @@ import com.vitorpamplona.amethyst.ui.components.DisplayBlurHash import com.vitorpamplona.amethyst.ui.components.ImageUrlWithDownloadButton import com.vitorpamplona.amethyst.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.ui.components.SensitivityWarning -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon import com.vitorpamplona.amethyst.ui.note.WatchAuthor import com.vitorpamplona.amethyst.ui.note.elements.BannerImage @@ -78,8 +78,8 @@ fun GalleryThumbnail( nav: INav, ratio: Float = 1.0f, ) { - val noteState by baseNote.live().metadata.observeAsState() - val noteEvent = noteState?.note?.event ?: return + val noteState by observeNote(baseNote, accountViewModel) + val noteEvent = noteState.note.event ?: return val content = if (noteEvent is ProfileGalleryEntryEvent) { @@ -98,7 +98,8 @@ fun GalleryThumbnail( MediaUrlImage( url = url, description = noteEvent.content, - hash = null, // We don't want to show the hash banner here + // We don't want to show the hash banner here + hash = null, blurhash = noteEvent.blurhash(), dim = noteEvent.dimensions(), uri = null, @@ -111,7 +112,8 @@ fun GalleryThumbnail( MediaUrlImage( url = imeta.url, description = noteEvent.content, - hash = null, // We don't want to show the hash banner here + // We don't want to show the hash banner here + hash = null, blurhash = imeta.blurhash, dim = imeta.dimension, uri = null, @@ -123,7 +125,8 @@ fun GalleryThumbnail( MediaUrlVideo( url = imeta.url, description = noteEvent.content, - hash = null, // We don't want to show the hash banner here + // We don't want to show the hash banner here + hash = null, blurhash = imeta.blurhash, dim = imeta.dimension, uri = null, @@ -147,14 +150,17 @@ fun InnerRenderGalleryThumb( if (content.isNotEmpty()) { GalleryContentView(content, accountViewModel, ratio = ratio) } else { - DisplayGalleryAuthorBanner(note) + DisplayGalleryAuthorBanner(note, accountViewModel) } } @Composable -fun DisplayGalleryAuthorBanner(note: Note) { - WatchAuthor(note) { author -> - BannerImage(author, Modifier.fillMaxSize().clip(QuoteBorder)) +fun DisplayGalleryAuthorBanner( + note: Note, + accountViewModel: AccountViewModel, +) { + WatchAuthor(note, accountViewModel) { author -> + BannerImage(author, Modifier.fillMaxSize().clip(QuoteBorder), accountViewModel) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt index a7d6c7c725..c4f8729cbb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt @@ -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 @@ -33,13 +33,12 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.FeedState import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.FeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.FeedPadding diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/QuickActionGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/QuickActionGallery.kt index f49fe889dc..778e99afdd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/QuickActionGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/QuickActionGallery.kt @@ -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 @@ -60,11 +60,11 @@ fun DeleteFromGalleryDialog( ) { QuickActionAlertDialogOneButton( title = stringRes(R.string.quick_action_request_deletion_gallery_title), - textContent = stringRes(R.string.quick_action_request_deletion_gallery_alert_body), + textContent = stringRes(R.string.quick_action_request_deletion_gallery_alert_body_v2), buttonIcon = Icons.Default.Delete, buttonText = stringRes(R.string.quick_action_delete_dialog_btn), onClickDoOnce = { - accountViewModel.removefromMediaGallery(note) + accountViewModel.removeFromMediaGallery(note) onDismiss() }, onDismiss = onDismiss, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/TabGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/TabGallery.kt index 613c1360ad..c1f49d05ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/TabGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/TabGallery.kt @@ -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,13 +26,14 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.SaveableGridFeedState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.dal.UserProfileGalleryFeedViewModel @Composable fun TabGallery( - feedViewModel: NostrUserProfileGalleryFeedViewModel, + feedViewModel: UserProfileGalleryFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileGalleryFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt similarity index 87% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileGalleryFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt index fc9b69852e..2866e57668 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileGalleryFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt @@ -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,15 +18,18 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoEvent @@ -75,10 +78,8 @@ class UserProfileGalleryFeedFilter( fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( - userHex = account.userProfile().pubkeyHex, - selectedListName = account.settings.defaultStoriesFollowList.value, followLists = account.liveStoriesFollowLists.value, - hiddenUsers = account.flowHiddenUsers.value, + hiddenUsers = account.hiddenUsers.flow.value, ) override fun sort(collection: Set): List = collection.sortedWith(DefaultFeedOrder) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/NostrUserProfileGalleryFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedViewModel.kt similarity index 77% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/NostrUserProfileGalleryFeedViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedViewModel.kt index e24d2b2f3f..93ff86b75f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/NostrUserProfileGalleryFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedViewModel.kt @@ -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,16 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.dal import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.dal.UserProfileGalleryFeedFilter import com.vitorpamplona.amethyst.ui.screen.FeedViewModel -class NostrUserProfileGalleryFeedViewModel( +class UserProfileGalleryFeedViewModel( val user: User, val account: Account, ) : FeedViewModel(UserProfileGalleryFeedFilter(user, account)) { @@ -35,8 +34,7 @@ class NostrUserProfileGalleryFeedViewModel( val user: User, val account: Account, ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrUserProfileGalleryFeedViewModel = - NostrUserProfileGalleryFeedViewModel(user, account) - as NostrUserProfileGalleryFeedViewModel + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = UserProfileGalleryFeedViewModel(user, account) as T } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/FollowedTagsTabHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/FollowedTagsTabHeader.kt index 301a5262e8..4c46aa32bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/FollowedTagsTabHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/FollowedTagsTabHeader.kt @@ -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,23 +22,19 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.hashtags import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserTagFollowCount +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @Composable -fun FollowedTagsTabHeader(baseUser: User) { - val userState by baseUser.live().follows.observeAsState() - - val usertags by remember(baseUser) { - derivedStateOf { - userState?.user?.latestContactList?.countFollowTags() ?: 0 - } - } +fun FollowedTagsTabHeader( + baseUser: User, + accountViewModel: AccountViewModel, +) { + val usertags by observeUserTagFollowCount(baseUser, accountViewModel) Text(text = "$usertags ${stringRes(R.string.followed_tags)}") } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/TabFollowedTags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/TabFollowedTags.kt index 0489c85630..718d2b814e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/TabFollowedTags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/TabFollowedTags.kt @@ -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,17 +22,19 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.hashtags import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserTagFollows +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagHeader import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -40,31 +42,26 @@ import com.vitorpamplona.amethyst.ui.theme.DividerThickness @Composable fun TabFollowedTags( baseUser: User, - account: AccountViewModel, + accountViewModel: AccountViewModel, nav: INav, ) { - val items = - remember(baseUser) { - baseUser.latestContactList?.unverifiedFollowTagSet() - } - Column( Modifier .fillMaxHeight() .padding(vertical = 0.dp), ) { - items?.let { - LazyColumn { - itemsIndexed(items) { index, hashtag -> - HashtagHeader( - tag = hashtag, - account = account, - onClick = { nav.nav(Route.Hashtag(hashtag)) }, - ) - HorizontalDivider( - thickness = DividerThickness, - ) - } + val items by observeUserTagFollows(baseUser, accountViewModel) + + LazyColumn(Modifier.fillMaxSize()) { + itemsIndexed(items) { index, hashtag -> + HashtagHeader( + tag = hashtag, + account = accountViewModel, + onClick = { nav.nav(Route.Hashtag(hashtag)) }, + ) + HorizontalDivider( + thickness = DividerThickness, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayFollowUnfollowButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayFollowUnfollowButton.kt index 6a474f8f31..e71428e605 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayFollowUnfollowButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayFollowUnfollowButton.kt @@ -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,11 +22,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.lifecycle.distinctUntilChanged -import androidx.lifecycle.map import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowing import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.FollowButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.UnfollowButton @@ -36,22 +34,8 @@ fun DisplayFollowUnfollowButton( baseUser: User, accountViewModel: AccountViewModel, ) { - val isLoggedInFollowingUser by - accountViewModel.account - .userProfile() - .live() - .follows - .map { it.user.isFollowing(baseUser) } - .distinctUntilChanged() - .observeAsState(initial = accountViewModel.account.isFollowing(baseUser)) - - val isUserFollowingLoggedIn by - baseUser - .live() - .follows - .map { it.user.isFollowing(accountViewModel.account.userProfile()) } - .distinctUntilChanged() - .observeAsState(initial = baseUser.isFollowing(accountViewModel.account.userProfile())) + val isLoggedInFollowingUser by observeUserIsFollowing(accountViewModel.account.userProfile(), baseUser, accountViewModel) + val isUserFollowingLoggedIn by observeUserIsFollowing(baseUser, accountViewModel.account.userProfile(), accountViewModel) if (isLoggedInFollowingUser) { UnfollowButton { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt index 75699dea8b..776bdc8c97 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt @@ -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,17 +26,17 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.actions.InformationDialog import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeToMessage +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog import com.vitorpamplona.amethyst.ui.note.LightningAddressIcon import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequestCard @@ -51,12 +51,11 @@ import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse @Composable fun DisplayLNAddress( lud16: String?, - userHex: String, + user: User, accountViewModel: AccountViewModel, nav: INav, ) { val context = LocalContext.current - val scope = rememberCoroutineScope() var zapExpanded by remember { mutableStateOf(false) } var showErrorMessageDialog by remember { mutableStateOf(null) } @@ -67,7 +66,7 @@ fun DisplayLNAddress( textContent = showErrorMessageDialog ?: "", onClickStartMessage = { nav.nav { - routeToMessage(userHex, showErrorMessageDialog, accountViewModel = accountViewModel) + routeToMessage(user, showErrorMessageDialog, accountViewModel = accountViewModel) } }, onDismiss = { showErrorMessageDialog = null }, @@ -105,13 +104,13 @@ fun DisplayLNAddress( ) { InvoiceRequestCard( lud16, - userHex, + user, accountViewModel, onSuccess = { zapExpanded = false // pay directly - if (accountViewModel.account.hasWalletConnectSetup()) { - accountViewModel.sendZapPaymentRequestFor(it, null, onSent = {}) { response -> + if (accountViewModel.account.nip47SignerState.hasWalletConnectSetup()) { + accountViewModel.sendZapPaymentRequestFor(it, null) { response -> if (response is PayInvoiceSuccessResponse) { showInfoMessageDialog = stringRes(context, R.string.payment_successful) } else if (response is PayInvoiceErrorResponse) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index 3ae5407be5..59fb554333 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -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 @@ -33,7 +33,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -42,22 +41,23 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUser import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.DisplayNip05ProfileStatus import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.DrawPlayName +import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.DisplayAppRecommendations -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.NostrUserAppRecommendationsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.UserAppRecommendationsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.badges.DisplayBadges import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.ShowQRDialog import com.vitorpamplona.amethyst.ui.stringRes @@ -76,14 +76,12 @@ import com.vitorpamplona.quartz.nip39ExtIdentities.identityClaims @Composable fun DrawAdditionalInfo( baseUser: User, - appRecommendations: NostrUserAppRecommendationsFeedViewModel, + appRecommendations: UserAppRecommendationsFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { - val userState by baseUser.live().metadata.observeAsState() - val user = remember(userState) { userState?.user } ?: return - val tags = userState?.user?.info?.tags - + val userState by observeUser(baseUser, accountViewModel) + val user = userState?.user ?: return val uri = LocalUriHandler.current val clipboardManager = LocalClipboardManager.current @@ -91,7 +89,7 @@ fun DrawAdditionalInfo( Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 7.dp)) { CreateTextWithEmoji( text = it, - tags = tags, + tags = user.info?.tags ?: EmptyTagList, fontWeight = FontWeight.Bold, fontSize = 25.sp, ) @@ -148,7 +146,7 @@ fun DrawAdditionalInfo( onClick = { dialogOpen = true }, ) { Icon( - painter = painterResource(R.drawable.ic_qrcode), + painter = painterRes(R.drawable.ic_qrcode, 1), contentDescription = stringRes(id = R.string.show_npub_as_a_qr_code), modifier = Size15Modifier, tint = MaterialTheme.colorScheme.placeholderText, @@ -188,9 +186,19 @@ fun DrawAdditionalInfo( } } - val lud16 = remember(userState) { user.info?.lud16?.trim() ?: user.info?.lud06?.trim() } - val pubkeyHex = remember { baseUser.pubkeyHex } - DisplayLNAddress(lud16, pubkeyHex, accountViewModel, nav) + val lud16 = + remember(userState) { + userState + ?.user + ?.info + ?.lud16 + ?.trim() ?: userState + ?.user + ?.info + ?.lud06 + ?.trim() + } + DisplayLNAddress(lud16, baseUser, accountViewModel, nav) val identities = user.latestMetadata?.identityClaims() if (!identities.isNullOrEmpty()) { @@ -198,7 +206,7 @@ fun DrawAdditionalInfo( Row(verticalAlignment = Alignment.CenterVertically) { Icon( tint = Color.Unspecified, - painter = painterResource(id = getIdentityClaimIcon(identity)), + painter = painterRes(resourceId = getIdentityClaimIcon(identity), getIdentityClaimIconReference(identity)), contentDescription = stringRes(getIdentityClaimDescription(identity)), modifier = Modifier.size(16.dp), ) @@ -255,3 +263,12 @@ fun getIdentityClaimDescription(identity: IdentityClaimTag): Int = is GitHubIdentity -> R.string.github else -> R.drawable.github } + +fun getIdentityClaimIconReference(identity: IdentityClaimTag): Int = + when (identity) { + is TwitterIdentity -> 0 + is TelegramIdentity -> 0 + is MastodonIdentity -> 0 + is GitHubIdentity -> 0 + else -> 0 + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt index ff883a05c9..fd87e10452 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt @@ -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,21 +27,21 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserBanner import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog +import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -51,18 +51,26 @@ fun DrawBanner( baseUser: User, accountViewModel: AccountViewModel, ) { - val userState by baseUser.live().metadata.observeAsState() - val banner = remember(userState) { userState?.user?.info?.banner } + val banner by observeUserBanner(baseUser, accountViewModel) - val clipboardManager = LocalClipboardManager.current - var zoomImageDialogOpen by remember { mutableStateOf(false) } + DrawBanner(banner, accountViewModel) +} +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun DrawBanner( + banner: String?, + accountViewModel: AccountViewModel, +) { if (!banner.isNullOrBlank()) { + val clipboardManager = LocalClipboardManager.current + var zoomImageDialogOpen by remember { mutableStateOf(false) } + AsyncImage( model = banner, contentDescription = stringRes(id = R.string.profile_image), contentScale = ContentScale.FillWidth, - placeholder = painterResource(R.drawable.profile_banner), + placeholder = painterRes(R.drawable.profile_banner, 1), modifier = Modifier .fillMaxWidth() @@ -82,7 +90,7 @@ fun DrawBanner( } } else { Image( - painter = painterResource(R.drawable.profile_banner), + painter = painterRes(R.drawable.profile_banner, 2), contentDescription = stringRes(id = R.string.profile_banner), contentScale = ContentScale.FillWidth, modifier = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditButton.kt index a1b754695e..9a1a41d06a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditButton.kt @@ -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 @@ -31,8 +31,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ZeroPadding diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/MessageButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/MessageButton.kt index 3e09abc6f6..16bf12de6b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/MessageButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/MessageButton.kt @@ -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,21 +24,20 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextFieldDefaults.contentPadding import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage +import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.ZeroPadding -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey @Composable fun MessageButton( @@ -46,20 +45,23 @@ fun MessageButton( accountViewModel: AccountViewModel, nav: INav, ) { - val scope = rememberCoroutineScope() - FilledTonalButton( modifier = Modifier .padding(horizontal = 3.dp) .width(50.dp), onClick = { - scope.launch(Dispatchers.IO) { accountViewModel.createChatRoomFor(user) { nav.nav(Route.Room(it)) } } + nav.nav { + routeToMessage( + room = ChatroomKey(setOf(user.pubkeyHex)), + account = accountViewModel.account, + ) + } }, contentPadding = ZeroPadding, ) { Icon( - painter = painterResource(R.drawable.ic_dm), + painter = painterRes(R.drawable.ic_dm, 1), stringRes(R.string.send_a_direct_message), modifier = Size20Modifier, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileActions.kt index ed22b129ce..a8fc7f3ab1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileActions.kt @@ -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 @@ -28,10 +28,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.toMutableStateList import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.observeAccountIsHiddenUser +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.ShowUserButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.WatchIsHiddenUser @Composable fun ProfileActions( @@ -48,12 +48,12 @@ fun ProfileActions( EditButton(nav) } - WatchIsHiddenUser(baseUser, accountViewModel) { isHidden -> - if (isHidden) { - ShowUserButton { accountViewModel.showUser(baseUser.pubkeyHex) } - } else { - DisplayFollowUnfollowButton(baseUser, accountViewModel) - } + val isHidden by observeAccountIsHiddenUser(accountViewModel.account, baseUser) + + if (isHidden) { + ShowUserButton { accountViewModel.showUser(baseUser.pubkeyHex) } + } else { + DisplayFollowUnfollowButton(baseUser, accountViewModel) } FollowSetsActionMenu( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt index 39ddcb2c56..0ef030fde9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt @@ -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 @@ -51,10 +51,10 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.NostrUserAppRecommendationsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.UserAppRecommendationsFeedViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.Size100dp @@ -66,7 +66,7 @@ import com.vitorpamplona.amethyst.ui.theme.userProfileBorderModifier @Composable fun ProfileHeader( baseUser: User, - appRecommendations: NostrUserAppRecommendationsFeedViewModel, + appRecommendations: UserAppRecommendationsFeedViewModel, nav: INav, accountViewModel: AccountViewModel, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt index 2a09eb2061..c42054030c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt @@ -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 @@ -29,7 +29,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.AnnotatedString -import androidx.core.content.ContextCompat import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.note.externalLinkForUser @@ -59,7 +58,7 @@ fun UserProfileDropDownMenu( }, ) - val actContext = LocalContext.current + val context = LocalContext.current DropdownMenuItem( text = { Text(stringRes(R.string.quick_action_share)) }, @@ -74,13 +73,13 @@ fun UserProfileDropDownMenu( ) putExtra( Intent.EXTRA_TITLE, - stringRes(actContext, R.string.quick_action_share_browser_link), + stringRes(context, R.string.quick_action_share_browser_link), ) } val shareIntent = - Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share)) - ContextCompat.startActivity(actContext, shareIntent, null) + Intent.createChooser(sendIntent, stringRes(context, R.string.quick_action_share)) + context.startActivity(shareIntent) onDismiss() }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/DisplayAppRecommendations.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/DisplayAppRecommendations.kt index 14aa1f004d..fc6b2359b0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/DisplayAppRecommendations.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/DisplayAppRecommendations.kt @@ -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 @@ -36,13 +36,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedState -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.amethyst.ui.stringRes @Composable fun DisplayAppRecommendations( - appRecommendations: NostrUserAppRecommendationsFeedViewModel, + appRecommendations: UserAppRecommendationsFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { @@ -60,7 +60,7 @@ fun DisplayAppRecommendations( Column { Text(stringRes(id = R.string.recommended_apps)) - Recommends(state, nav) + Recommends(state, accountViewModel, nav) } } else -> {} @@ -72,6 +72,7 @@ fun DisplayAppRecommendations( @OptIn(ExperimentalLayoutApi::class) fun Recommends( loaded: FeedState.Loaded, + accountViewModel: AccountViewModel, nav: INav, ) { val items by loaded.feed.collectAsStateWithLifecycle() @@ -79,6 +80,6 @@ fun Recommends( verticalArrangement = Arrangement.Center, modifier = Modifier.padding(vertical = 5.dp), ) { - items.list.forEach { app -> WatchApp(app, nav) } + items.list.forEach { app -> WatchApp(app, accountViewModel, nav) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/NostrUserAppRecommendationsFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/UserAppRecommendationsFeedViewModel.kt similarity index 76% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/NostrUserAppRecommendationsFeedViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/UserAppRecommendationsFeedViewModel.kt index fdc0bf6c44..2d169e701b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/NostrUserAppRecommendationsFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/UserAppRecommendationsFeedViewModel.kt @@ -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,17 +23,15 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.dal.UserProfileAppRecommendationsFeedFilter import com.vitorpamplona.amethyst.ui.screen.FeedViewModel -class NostrUserAppRecommendationsFeedViewModel( +class UserAppRecommendationsFeedViewModel( val user: User, ) : FeedViewModel(UserProfileAppRecommendationsFeedFilter(user)) { class Factory( val user: User, ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrUserAppRecommendationsFeedViewModel = - NostrUserAppRecommendationsFeedViewModel(user) - as NostrUserAppRecommendationsFeedViewModel + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = UserAppRecommendationsFeedViewModel(user) as T } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileAppRecommendationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/UserProfileAppRecommendationsFeedFilter.kt similarity index 91% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileAppRecommendationsFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/UserProfileAppRecommendationsFeedFilter.kt index af5b729fc3..9ae74d251e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileAppRecommendationsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/UserProfileAppRecommendationsFeedFilter.kt @@ -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,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent class UserProfileAppRecommendationsFeedFilter( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/WatchApp.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/WatchApp.kt index 77d45ac858..2ead89f33f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/WatchApp.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/WatchApp.kt @@ -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,7 +27,6 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -35,8 +34,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import kotlinx.coroutines.Dispatchers @@ -45,9 +46,10 @@ import kotlinx.coroutines.withContext @Composable fun WatchApp( baseApp: Note, + accountViewModel: AccountViewModel, nav: INav, ) { - val appState by baseApp.live().metadata.observeAsState() + val appState by observeNote(baseApp, accountViewModel) var appLogo by remember(baseApp) { mutableStateOf(null) } var appName by remember(baseApp) { mutableStateOf(null) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt index 4b61a4793d..0311015e86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt @@ -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,50 +20,43 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.badges +import android.R.attr.onClick import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CutCornerShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.lifecycle.distinctUntilChanged -import androidx.lifecycle.map import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEventAndMap import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav.nav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.BadgePictureModifier +import com.vitorpamplona.amethyst.ui.theme.Size35Modifier import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext @Composable fun DisplayBadges( @@ -76,12 +69,7 @@ fun DisplayBadges( accountViewModel, ) { note -> if (note != null) { - WatchAndRenderBadgeList( - note = note, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, - loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, - nav = nav, - ) + WatchAndRenderBadgeList(note, accountViewModel, nav) } } } @@ -89,155 +77,135 @@ fun DisplayBadges( @Composable private fun WatchAndRenderBadgeList( note: AddressableNote, - loadProfilePicture: Boolean, - loadRobohash: Boolean, + accountViewModel: AccountViewModel, nav: INav, ) { - val badgeList by - note - .live() - .metadata - .map { (it.note.event as? BadgeProfilesEvent)?.badgeAwardEvents()?.toImmutableList() } - .distinctUntilChanged() - .observeAsState() + val badgeList by observeNoteEventAndMap(note, accountViewModel) { event: BadgeProfilesEvent -> + event.badgeAwardEvents().toImmutableList() + } - badgeList?.let { list -> RenderBadgeList(list, loadProfilePicture, loadRobohash, nav) } + badgeList?.let { list -> RenderBadgeList(list, accountViewModel, nav) } } @Composable @OptIn(ExperimentalLayoutApi::class) private fun RenderBadgeList( list: ImmutableList, - loadProfilePicture: Boolean, - loadRobohash: Boolean, + accountViewModel: AccountViewModel, nav: INav, ) { FlowRow( verticalArrangement = Arrangement.Center, modifier = Modifier.padding(vertical = 5.dp), ) { - list.forEach { badgeAwardEvent -> LoadAndRenderBadge(badgeAwardEvent, loadProfilePicture, loadRobohash, nav) } + list.forEach { badgeAwardEvent -> LoadAndRenderBadge(badgeAwardEvent, accountViewModel, nav) } } } @Composable private fun LoadAndRenderBadge( badgeAwardEvent: ETag, - loadProfilePicture: Boolean, - loadRobohash: Boolean, + accountViewModel: AccountViewModel, nav: INav, ) { - var baseNote by remember(badgeAwardEvent) { mutableStateOf(LocalCache.getNoteIfExists(badgeAwardEvent)) } - - LaunchedEffect(key1 = badgeAwardEvent) { - if (baseNote == null) { - withContext(Dispatchers.IO) { - baseNote = LocalCache.checkGetOrCreateNote(badgeAwardEvent) + val baseNote = + produceState( + LocalCache.getNoteIfExists(badgeAwardEvent), + badgeAwardEvent, + ) { + val newValue = LocalCache.checkGetOrCreateNote(badgeAwardEvent) + if (newValue != value) { + value = newValue } } - } - baseNote?.let { ObserveAndRenderBadge(it, loadProfilePicture, loadRobohash, nav) } + baseNote.value?.let { + ObserveAndRenderBadge(it, accountViewModel, nav) + } } @Composable private fun ObserveAndRenderBadge( it: Note, - loadProfilePicture: Boolean, - loadRobohash: Boolean, + accountViewModel: AccountViewModel, nav: INav, ) { - val badgeAwardState by it.live().metadata.observeAsState() - val baseBadgeDefinition by - remember(badgeAwardState) { derivedStateOf { badgeAwardState?.note?.replyTo?.firstOrNull() } } - - baseBadgeDefinition?.let { BadgeThumb(it, loadProfilePicture, loadRobohash, nav, Size35dp) } -} - -@Composable -fun BadgeThumb( - note: Note, - loadProfilePicture: Boolean, - loadRobohash: Boolean, - nav: INav, - size: Dp, - pictureModifier: Modifier = Modifier, -) { - BadgeThumb(note, loadProfilePicture, loadRobohash, size, pictureModifier) { nav.nav(Route.Note(note.idHex)) } + val badgeAwardState by observeNoteEvent(it, accountViewModel) + val badgeDefinitionId = badgeAwardState?.awardDefinition()?.firstOrNull() + if (badgeDefinitionId != null) { + LoadAddressableNote(badgeDefinitionId, accountViewModel) { badgeDefNote -> + badgeDefNote?.let { + BadgeThumb(it, accountViewModel, nav) + } + } + } } @Composable fun BadgeThumb( baseNote: Note, - loadProfilePicture: Boolean, - loadRobohash: Boolean, - size: Dp, - pictureModifier: Modifier = Modifier, - onClick: ((String) -> Unit)? = null, + accountViewModel: AccountViewModel, + nav: INav, ) { Box( - remember { - Modifier - .width(size) - .height(size) - }, + modifier = + Size35Modifier.clickable( + onClick = { + nav.nav { + routeFor(baseNote, accountViewModel.account) + } + }, + ), ) { - WatchAndRenderBadgeImage(baseNote, loadProfilePicture, loadRobohash, size, pictureModifier, onClick) + WatchAndRenderBadgeImage(baseNote, accountViewModel) } } @Composable private fun WatchAndRenderBadgeImage( baseNote: Note, - loadProfilePicture: Boolean, - loadRobohash: Boolean, - size: Dp, - pictureModifier: Modifier, - onClick: ((String) -> Unit)?, + accountViewModel: AccountViewModel, ) { - val noteState by baseNote.live().metadata.observeAsState() - val eventId = remember(noteState) { noteState?.note?.idHex } ?: return - val image by - remember(noteState) { - derivedStateOf { - val event = noteState?.note?.event as? BadgeDefinitionEvent + val event by observeNoteEvent(baseNote, accountViewModel) + + event?.let { + val image = + remember(event) { event?.thumb()?.ifBlank { null } ?: event?.image()?.ifBlank { null } } + RenderBadgeImage(it.id, it.name(), image, accountViewModel) + } +} + +@Composable +private fun RenderBadgeImage( + id: String, + name: String?, + image: String?, + accountViewModel: AccountViewModel, +) { + val description = + if (name != null) { + stringRes(id = R.string.badge_award_image_for, name) + } else { + stringRes(id = R.string.badge_award_image) } if (image == null) { RobohashAsyncImage( - robot = "authornotfound", - contentDescription = stringRes(R.string.unknown_author), - modifier = - remember { - pictureModifier - .width(size) - .height(size) - }, - loadRobohash = loadRobohash, + robot = "badgenotfound", + contentDescription = description, + modifier = BadgePictureModifier, + loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, ) } else { RobohashFallbackAsyncImage( - robot = eventId, - model = image!!, - contentDescription = stringRes(id = R.string.profile_image), - modifier = - remember { - pictureModifier - .width(size) - .height(size) - .clip(shape = CutCornerShape(20)) - .run { - if (onClick != null) { - this.clickable(onClick = { onClick(eventId) }) - } else { - this - } - } - }, - loadProfilePicture = loadProfilePicture, - loadRobohash = loadRobohash, + robot = id, + model = image, + contentDescription = description, + modifier = BadgePictureModifier, + loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/TabMutualConversations.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/TabMutualConversations.kt index c06a0d66dd..9eef64867c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/TabMutualConversations.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/TabMutualConversations.kt @@ -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,13 +24,14 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual.dal.UserProfileMutualFeedViewModel @Composable fun TabMutualConversations( - feedViewModel: NostrUserProfileMutualFeedViewModel, + feedViewModel: UserProfileMutualFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileMutualFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileMutualFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt index bfa01ace9a..06954f841b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileMutualFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt @@ -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,13 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/NostrUserProfileMutualFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedViewModel.kt similarity index 78% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/NostrUserProfileMutualFeedViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedViewModel.kt index f234e96e77..36dfbc4ce1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/NostrUserProfileMutualFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedViewModel.kt @@ -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,16 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual.dal import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.dal.UserProfileMutualFeedFilter import com.vitorpamplona.amethyst.ui.screen.FeedViewModel -class NostrUserProfileMutualFeedViewModel( +class UserProfileMutualFeedViewModel( val user: User, val account: Account, ) : FeedViewModel(UserProfileMutualFeedFilter(user, account)) { @@ -35,8 +34,7 @@ class NostrUserProfileMutualFeedViewModel( val user: User, val account: Account, ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrUserProfileMutualFeedViewModel = - NostrUserProfileMutualFeedViewModel(user, account) - as NostrUserProfileMutualFeedViewModel + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = UserProfileMutualFeedViewModel(user, account) as T } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt index afb8cbb4d7..f799c97503 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt @@ -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,13 +24,14 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.dal.UserProfileNewThreadsFeedViewModel @Composable fun TabNotesNewThreads( - feedViewModel: NostrUserProfileNewThreadsFeedViewModel, + feedViewModel: UserProfileNewThreadsFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt index 6e56760647..0678a0564f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt @@ -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,13 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent @@ -38,6 +40,7 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent class UserProfileNewThreadFeedFilter( val user: User, @@ -77,6 +80,7 @@ class UserProfileNewThreadFeedFilter( it.event is InteractiveStoryPrologueEvent || it.event is AudioTrackEvent || it.event is AudioHeaderEvent || + it.event is VoiceEvent || it.event is TorrentEvent ) && it.isNewThread() && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/NostrUserProfileNewThreadsFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadsFeedViewModel.kt similarity index 77% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/NostrUserProfileNewThreadsFeedViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadsFeedViewModel.kt index 1032d0bef8..0a92c613fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/NostrUserProfileNewThreadsFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadsFeedViewModel.kt @@ -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,16 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.dal import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.screen.FeedViewModel -class NostrUserProfileNewThreadsFeedViewModel( +class UserProfileNewThreadsFeedViewModel( val user: User, val account: Account, ) : FeedViewModel(UserProfileNewThreadFeedFilter(user, account)) { @@ -35,8 +34,7 @@ class NostrUserProfileNewThreadsFeedViewModel( val user: User, val account: Account, ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrUserProfileNewThreadsFeedViewModel = - NostrUserProfileNewThreadsFeedViewModel(user, account) - as NostrUserProfileNewThreadsFeedViewModel + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = UserProfileNewThreadsFeedViewModel(user, account) as T } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt index 6e97d92526..af1206d858 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt @@ -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,10 +26,13 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.model.RelayInfo import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.RelayCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -51,21 +54,32 @@ fun RelayFeedView( contentPadding = FeedPadding, state = listState, ) { - itemsIndexed(feedState, key = { _, item -> item.url }) { _, item -> - RelayCompose( - item, - accountViewModel = accountViewModel, - onAddRelay = { - nav.nav(Route.EditRelays(item.url)) - }, - onRemoveRelay = { - nav.nav(Route.EditRelays(item.url)) - }, - ) - HorizontalDivider( - thickness = DividerThickness, - ) + itemsIndexed(feedState, key = { _, item -> item.url.url }) { _, item -> + RenderRelayRow(item, accountViewModel, nav) } } } } + +@Composable +private fun RenderRelayRow( + relay: RelayInfo, + accountViewModel: AccountViewModel, + nav: INav, +) { + val clipboardManager = LocalClipboardManager.current + RelayCompose( + relay, + accountViewModel = accountViewModel, + onAddRelay = { + clipboardManager.setText(AnnotatedString(relay.url.url)) + nav.nav(Route.EditRelays) + }, + onRemoveRelay = { + nav.nav(Route.EditRelays) + }, + ) + HorizontalDivider( + thickness = DividerThickness, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt index a40162b8d9..7c8333adc5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt @@ -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 @@ -28,13 +28,17 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.RelayInfo import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.model.UserState import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent import com.vitorpamplona.ammolite.relays.BundledUpdate -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip02FollowList.ReadWrite import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -45,61 +49,79 @@ class RelayFeedViewModel : val order = compareByDescending { it.lastEvent } .thenByDescending { it.counter } - .thenBy { it.url } + .thenBy { it.url.url } private val _feedContent = MutableStateFlow>(emptyList()) val feedContent = _feedContent.asStateFlow() var currentUser: User? = null + var currentJob: Job? = null override val isRefreshing: MutableState = mutableStateOf(false) fun refresh() { - viewModelScope.launch(Dispatchers.Default) { refreshSuspended() } + viewModelScope.launch(Dispatchers.Default) { + refreshSuspended() + } } fun refreshSuspended() { try { isRefreshing.value = true - val beingUsed = currentUser?.relaysBeingUsed?.values ?: emptyList() - val beingUsedSet = currentUser?.relaysBeingUsed?.keys ?: emptySet() - - val newRelaysFromRecord = - currentUser?.latestContactList?.relays()?.entries?.mapNotNullTo(HashSet()) { - val url = RelayUrlFormatter.normalize(it.key) - if (url !in beingUsedSet) { - RelayInfo(url, 0, 0) - } else { - null - } - } - ?: emptyList() - - val newList = (beingUsed + newRelaysFromRecord).sortedWith(order) - - _feedContent.update { newList } + currentUser?.let { + val newList = mergeRelays(it.relaysBeingUsed, it.latestContactList?.relays()) + _feedContent.update { newList } + } } finally { isRefreshing.value = false } } - val listener: (UserState) -> Unit = { invalidateData() } + fun mergeRelays( + relaysBeingUsed: Map, + relays: Map?, + ): List { + val userRelaysBeingUsed = relaysBeingUsed.map { it.value } + val currentUserRelays = + relays?.mapNotNull { + val url = it.key + if (url !in relaysBeingUsed) { + RelayInfo(url, 0, 0) + } else { + null + } + } ?: emptyList() + + return (userRelaysBeingUsed + currentUserRelays).sortedWith(order) + } + + @OptIn(FlowPreview::class) fun subscribeTo(user: User) { if (currentUser != user) { currentUser = user - user.live().relays.observeForever(listener) - user.live().relayInfo.observeForever(listener) + + currentJob?.cancel() + currentJob = + viewModelScope.launch { + combine(currentUser!!.flow().relays.stateFlow, currentUser!!.flow().relayInfo.stateFlow) { relays, relayInfo -> + mergeRelays(relays.user.relaysBeingUsed, relayInfo.user.latestContactList?.relays()) + }.debounce(1000) + .collect { newList -> + _feedContent.update { newList } + } + } + invalidateData() } } fun unsubscribeTo(user: User) { if (currentUser == user) { - user.live().relays.removeObserver(listener) - user.live().relayInfo.removeObserver(listener) currentUser = null + currentJob?.cancel() + invalidateData() } } @@ -116,6 +138,7 @@ class RelayFeedViewModel : override fun onCleared() { Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") bundler.cancel() + currentJob?.cancel() super.onCleared() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelaysTabHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelaysTabHeader.kt index 5b000bc80d..158d1be4cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelaysTabHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelaysTabHeader.kt @@ -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,26 +23,20 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.relays import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserRelaysUsing +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @Composable -fun RelaysTabHeader(baseUser: User) { - val userState by baseUser.live().relays.observeAsState() - val userRelaysBeingUsed = remember(userState) { userState?.user?.relaysBeingUsed?.size ?: "--" } +fun RelaysTabHeader( + baseUser: User, + accountViewModel: AccountViewModel, +) { + val userState by observeUserRelaysUsing(baseUser, accountViewModel) - val userStateRelayInfo by baseUser.live().relayInfo.observeAsState() - val userRelays = - remember(userStateRelayInfo) { - userStateRelayInfo - ?.user - ?.latestContactList - ?.relays() - ?.size ?: "--" - } - - Text(text = "$userRelaysBeingUsed / $userRelays ${stringRes(R.string.relays)}") + Text(text = "${sizeAsString(userState.userRelayList.size)} / ${sizeAsString(userState.relays.size)} ${stringRes(R.string.relays)}") } + +private fun sizeAsString(count: Int) = if (count > 0) count.toString() else "--" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/TabRelays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/TabRelays.kt index 6b2a036f67..2a2a4258af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/TabRelays.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/TabRelays.kt @@ -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 @@ -30,7 +30,7 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable @@ -64,8 +64,6 @@ fun TabRelays( lifeCycleOwner.lifecycle.addObserver(observer) onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) - println("Profile Relay Dispose") - feedViewModel.unsubscribeTo(user) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/ReportsTabHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/ReportsTabHeader.kt index 8f99331482..f6fc2b3eac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/ReportsTabHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/ReportsTabHeader.kt @@ -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,33 +22,23 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserReportCount +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch @Composable -fun ReportsTabHeader(baseUser: User) { - val userState by baseUser.live().reports.observeAsState() - var userReports by remember { mutableIntStateOf(0) } +fun ReportsTabHeader( + baseUser: User, + accountViewModel: AccountViewModel, +) { + val reportCount by observeUserReportCount(baseUser, accountViewModel) - LaunchedEffect(key1 = userState) { - launch(Dispatchers.IO) { - val newSize = UserProfileReportsFeedFilter(baseUser).feed().size - - if (newSize != userReports) { - userReports = newSize - } - } + if (reportCount > 0) { + Text(text = stringRes(R.string.number_reports, reportCount)) + } else { + Text(text = stringRes(R.string.reports)) } - - Text(text = "$userReports ${stringRes(R.string.reports)}") } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/TabReports.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/TabReports.kt index 784d86f45b..8701d7e946 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/TabReports.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/TabReports.kt @@ -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,18 +25,19 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal.UserProfileReportFeedViewModel @Composable fun TabReports( baseUser: User, - feedViewModel: NostrUserProfileReportFeedViewModel, + feedViewModel: UserProfileReportFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { - WatchReportsAndUpdateFeed(baseUser, feedViewModel) + WatchReportsAndUpdateFeed(baseUser, feedViewModel, accountViewModel) Column(Modifier.fillMaxHeight()) { RefresheableFeedView( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/WatchReportsAndUpdateFeed.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/WatchReportsAndUpdateFeed.kt index 78cd741726..a146eac622 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/WatchReportsAndUpdateFeed.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/WatchReportsAndUpdateFeed.kt @@ -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,14 +23,17 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserReports +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal.UserProfileReportFeedViewModel @Composable fun WatchReportsAndUpdateFeed( baseUser: User, - feedViewModel: NostrUserProfileReportFeedViewModel, + feedViewModel: UserProfileReportFeedViewModel, + accountViewModel: AccountViewModel, ) { - val userState by baseUser.live().reports.observeAsState() + val userState by observeUserReports(baseUser, accountViewModel) LaunchedEffect(userState) { feedViewModel.invalidateData() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/NostrUserProfileReportFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportFeedViewModel.kt similarity index 78% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/NostrUserProfileReportFeedViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportFeedViewModel.kt index 4aee303be8..b01ab99368 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/NostrUserProfileReportFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportFeedViewModel.kt @@ -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,20 +18,20 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter import com.vitorpamplona.amethyst.ui.screen.FeedViewModel -class NostrUserProfileReportFeedViewModel( +class UserProfileReportFeedViewModel( val user: User, ) : FeedViewModel(UserProfileReportsFeedFilter(user)) { class Factory( val user: User, ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrUserProfileReportFeedViewModel = NostrUserProfileReportFeedViewModel(user) as NostrUserProfileReportFeedViewModel + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = UserProfileReportFeedViewModel(user) as T } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportsFeedFilter.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportsFeedFilter.kt index f44d734f27..9c64ffba9e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportsFeedFilter.kt @@ -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,10 +18,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip56Reports.ReportEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedState.kt index 421b2369fb..868c1fc973 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedState.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedView.kt index 3c237c5ff8..acd98d7cb1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedView.kt @@ -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 @@ -35,7 +35,7 @@ import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.ZapNoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedViewModel.kt index 7fffd030a6..b7769fb46a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedViewModel.kt @@ -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,7 +25,6 @@ import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.LocalCache.notes import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists @@ -33,7 +32,6 @@ import com.vitorpamplona.ammolite.relays.BundledUpdate import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch @@ -86,26 +84,23 @@ open class LnZapFeedViewModel( } } - var collectorJob: Job? = null - init { Log.d("Init", "${this.javaClass.simpleName}") - collectorJob = - viewModelScope.launch(Dispatchers.IO) { - checkNotInMainThread() - - LocalCache.live.newEventBundles.collect { newNotes -> - checkNotInMainThread() - - invalidateData() - } + viewModelScope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { newNotes -> + invalidateData() } + } + viewModelScope.launch(Dispatchers.IO) { + LocalCache.live.deletedEventBundles.collect { newNotes -> + invalidateData() + } + } } override fun onCleared() { Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") bundler.cancel() - collectorJob?.cancel() super.onCleared() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/ShowUserButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/ShowUserButton.kt index 45d5c35124..c87e7365d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/ShowUserButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/ShowUserButton.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/TabReceivedZaps.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/TabReceivedZaps.kt index 0355ca81ae..e51280ff2f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/TabReceivedZaps.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/TabReceivedZaps.kt @@ -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,18 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.model.User -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.amethyst.ui.screen.loggedIn.profile.zaps.dal.UserProfileZapsFeedViewModel @Composable fun TabReceivedZaps( baseUser: User, - zapFeedViewModel: NostrUserProfileZapsFeedViewModel, + zapFeedViewModel: UserProfileZapsFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { - WatchZapsAndUpdateFeed(baseUser, zapFeedViewModel) + WatchZapsAndUpdateFeed(baseUser, zapFeedViewModel, accountViewModel) Column(Modifier.fillMaxHeight()) { LnZapFeedView(zapFeedViewModel, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/WatchZapsAndUpdateFeed.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/WatchZapsAndUpdateFeed.kt index cd2fec5d04..4930c11844 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/WatchZapsAndUpdateFeed.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/WatchZapsAndUpdateFeed.kt @@ -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,15 +23,18 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserZaps +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.dal.UserProfileZapsFeedViewModel @Composable fun WatchZapsAndUpdateFeed( baseUser: User, - feedViewModel: NostrUserProfileZapsFeedViewModel, + feedViewModel: UserProfileZapsFeedViewModel, + accountViewModel: AccountViewModel, ) { - val userState by baseUser.live().zaps.observeAsState() + val userState by observeUserZaps(baseUser, accountViewModel) LaunchedEffect(userState) { feedViewModel.invalidateData() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/ZapTabHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/ZapTabHeader.kt index 1a8eb58ef3..c2d73597a0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/ZapTabHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/ZapTabHeader.kt @@ -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,33 +22,20 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserZapAmount import com.vitorpamplona.amethyst.ui.note.showAmountInteger +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import java.math.BigDecimal @Composable -fun ZapTabHeader(baseUser: User) { - val userState by baseUser.live().zaps.observeAsState() - var zapAmount by remember { mutableStateOf(null) } - - LaunchedEffect(key1 = userState) { - launch(Dispatchers.Default) { - val tempAmount = baseUser.zappedAmount() - if (zapAmount != tempAmount) { - zapAmount = tempAmount - } - } - } +fun ZapTabHeader( + baseUser: User, + accountViewModel: AccountViewModel, +) { + val zapAmount by observeUserZapAmount(baseUser, accountViewModel) Text(text = "${showAmountInteger(zapAmount)} ${stringRes(id = R.string.zaps)}") } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsFeedFilter.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsFeedFilter.kt index ebb0d75670..06ea5284c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsFeedFilter.kt @@ -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,10 +18,11 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.dal import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.ZapReqResponse import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/NostrUserProfileZapsFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsFeedViewModel.kt similarity index 78% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/NostrUserProfileZapsFeedViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsFeedViewModel.kt index f1d8a66ac3..40ef049d6b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/NostrUserProfileZapsFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsFeedViewModel.kt @@ -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,19 +18,20 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.dal import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.dal.UserProfileZapsFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.LnZapFeedViewModel -class NostrUserProfileZapsFeedViewModel( +class UserProfileZapsFeedViewModel( user: User, ) : LnZapFeedViewModel(UserProfileZapsFeedFilter(user)) { class Factory( val user: User, ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrUserProfileZapsFeedViewModel = NostrUserProfileZapsFeedViewModel(user) as NostrUserProfileZapsFeedViewModel + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = UserProfileZapsFeedViewModel(user) as T } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeDrawer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeDrawer.kt index 7ac4b833da..85760a4475 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeDrawer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeDrawer.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeScanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeScanner.kt index 82dbf6a32a..3505d8b848 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeScanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeScanner.kt @@ -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 @@ -28,16 +28,20 @@ import com.google.zxing.client.android.Intents import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanOptions import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.uriToRoute import kotlinx.coroutines.CancellationException @Composable -fun NIP19QrCodeScanner(onScan: (Route?) -> Unit) { +fun NIP19QrCodeScanner( + accountViewModel: AccountViewModel, + onScan: (Route?) -> Unit, +) { SimpleQrCodeScanner { try { - onScan(uriToRoute(it)) + onScan(uriToRoute(it, accountViewModel.account)) } catch (e: Throwable) { if (e is CancellationException) throw e Log.e("NIP19 Scanner", "Error parsing $it", e) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRDialog.kt index 833b489da6..98d79371ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRDialog.kt @@ -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 @@ -62,7 +62,7 @@ import com.vitorpamplona.amethyst.ui.components.DisplayNIP05 import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge import com.vitorpamplona.amethyst.ui.components.nip05VerificationAsAState -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel @@ -220,7 +220,7 @@ fun ShowQRDialog( } } } else { - NIP19QrCodeScanner { + NIP19QrCodeScanner(accountViewModel) { if (it == null) { presenting = true } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/redirect/LoadRedirectScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/redirect/LoadRedirectScreen.kt new file mode 100644 index 0000000000..bb6e918c15 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/redirect/LoadRedirectScreen.kt @@ -0,0 +1,93 @@ +/** + * 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.ui.screen.loggedIn.redirect + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +fun LoadRedirectScreen( + eventId: String?, + accountViewModel: AccountViewModel, + nav: Nav, +) { + if (eventId == null) return + + LoadNote(eventId, accountViewModel) { note -> + note?.let { + LoadRedirectScreen( + baseNote = note, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} + +@Composable +fun LoadRedirectScreen( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteState by observeNote(baseNote, accountViewModel) + + LaunchedEffect(key1 = noteState) { + val event = noteState.note.event + if (event != null) { + withContext(Dispatchers.IO) { + routeFor(event, accountViewModel.account)?.let { route -> + nav.popUpTo(route, Route.EventRedirect::class) + } + } + } + } + + Column( + Modifier.fillMaxHeight().fillMaxWidth().padding(horizontal = 50.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(stringRes(R.string.looking_for_event, baseNote.idHex)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt index e52a465db8..0f86454ea4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt @@ -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,13 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -35,31 +32,31 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.DefaultDMRelayList +import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList import com.vitorpamplona.amethyst.model.DefaultSearchRelayList -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.blocked.BlockedRelayListViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.blocked.renderBlockedItems +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.broadcast.BroadcastRelayListViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.broadcast.renderBroadcastItems import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected.ConnectedRelayListViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected.renderConnectedItems import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm.DMRelayListViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm.renderDMItems -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kind3.Kind3RelayListViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kind3.renderKind3Items -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kind3.renderKind3ProposalItems +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.indexer.IndexerRelayListViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.indexer.renderIndexerItems import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.local.LocalRelayListViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.local.renderLocalItems import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip37.PrivateOutboxRelayListViewModel @@ -67,115 +64,139 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip37.renderPrivateO import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip65.Nip65RelayListViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip65.renderNip65HomeItems import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip65.renderNip65NotifItems +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.proxy.ProxyRelayListViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.proxy.renderProxyItems import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search.SearchRelayListViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search.renderSearchItems +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.trusted.TrustedRelayListViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.trusted.renderTrustedItems import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.FeedPadding -import com.vitorpamplona.amethyst.ui.theme.MinHorzSpacer import com.vitorpamplona.amethyst.ui.theme.RowColSpacing -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier +import com.vitorpamplona.amethyst.ui.theme.SettingsCategorySpacingModifier import com.vitorpamplona.amethyst.ui.theme.grayText -import com.vitorpamplona.ammolite.relays.Constants @Composable fun AllRelayListScreen( - relayToAdd: String? = null, accountViewModel: AccountViewModel, nav: INav, ) { - MappedAllRelayListView(relayToAdd ?: "", accountViewModel, nav) + val dmViewModel: DMRelayListViewModel = viewModel() + val nip65ViewModel: Nip65RelayListViewModel = viewModel() + val privateOutboxViewModel: PrivateOutboxRelayListViewModel = viewModel() + val searchViewModel: SearchRelayListViewModel = viewModel() + val blockedViewModel: BlockedRelayListViewModel = viewModel() + val trustedViewModel: TrustedRelayListViewModel = viewModel() + val localViewModel: LocalRelayListViewModel = viewModel() + val connectedViewModel: ConnectedRelayListViewModel = viewModel() + val broadcastViewModel: BroadcastRelayListViewModel = viewModel() + val indexerViewModel: IndexerRelayListViewModel = viewModel() + val proxyViewModel: ProxyRelayListViewModel = viewModel() + + dmViewModel.init(accountViewModel) + nip65ViewModel.init(accountViewModel) + searchViewModel.init(accountViewModel) + localViewModel.init(accountViewModel) + privateOutboxViewModel.init(accountViewModel) + connectedViewModel.init(accountViewModel) + blockedViewModel.init(accountViewModel) + trustedViewModel.init(accountViewModel) + broadcastViewModel.init(accountViewModel) + indexerViewModel.init(accountViewModel) + proxyViewModel.init(accountViewModel) + + LaunchedEffect(accountViewModel) { + dmViewModel.load() + nip65ViewModel.load() + searchViewModel.load() + localViewModel.load() + privateOutboxViewModel.load() + connectedViewModel.load() + blockedViewModel.load() + trustedViewModel.load() + broadcastViewModel.load() + indexerViewModel.load() + proxyViewModel.load() + } + + MappedAllRelayListView( + dmViewModel, + nip65ViewModel, + searchViewModel, + localViewModel, + privateOutboxViewModel, + connectedViewModel, + blockedViewModel, + trustedViewModel, + broadcastViewModel, + indexerViewModel, + proxyViewModel, + accountViewModel, + nav, + ) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun MappedAllRelayListView( - relayToAdd: String = "", + dmViewModel: DMRelayListViewModel, + nip65ViewModel: Nip65RelayListViewModel, + searchViewModel: SearchRelayListViewModel, + localViewModel: LocalRelayListViewModel, + privateOutboxViewModel: PrivateOutboxRelayListViewModel, + connectedViewModel: ConnectedRelayListViewModel, + blockedViewModel: BlockedRelayListViewModel, + trustedViewModel: TrustedRelayListViewModel, + broadcastViewModel: BroadcastRelayListViewModel, + indexerViewModel: IndexerRelayListViewModel, + proxyViewModel: ProxyRelayListViewModel, accountViewModel: AccountViewModel, newNav: INav, ) { - val kind3ViewModel: Kind3RelayListViewModel = viewModel() - val kind3FeedState by kind3ViewModel.relays.collectAsStateWithLifecycle() - val kind3Proposals by kind3ViewModel.proposedRelays.collectAsStateWithLifecycle() - - val dmViewModel: DMRelayListViewModel = viewModel() val dmFeedState by dmViewModel.relays.collectAsStateWithLifecycle() - - val nip65ViewModel: Nip65RelayListViewModel = viewModel() val homeFeedState by nip65ViewModel.homeRelays.collectAsStateWithLifecycle() val notifFeedState by nip65ViewModel.notificationRelays.collectAsStateWithLifecycle() - - val privateOutboxViewModel: PrivateOutboxRelayListViewModel = viewModel() val privateOutboxFeedState by privateOutboxViewModel.relays.collectAsStateWithLifecycle() - - val searchViewModel: SearchRelayListViewModel = viewModel() val searchFeedState by searchViewModel.relays.collectAsStateWithLifecycle() - - val localViewModel: LocalRelayListViewModel = viewModel() + val blockedFeedState by blockedViewModel.relays.collectAsStateWithLifecycle() + val trustedFeedState by trustedViewModel.relays.collectAsStateWithLifecycle() val localFeedState by localViewModel.relays.collectAsStateWithLifecycle() - - LaunchedEffect(Unit) { - kind3ViewModel.load(accountViewModel.account) - dmViewModel.load(accountViewModel.account) - nip65ViewModel.load(accountViewModel.account) - searchViewModel.load(accountViewModel.account) - localViewModel.load(accountViewModel.account) - privateOutboxViewModel.load(accountViewModel.account) - } + val connectedRelays by connectedViewModel.relays.collectAsStateWithLifecycle() + val broadcastRelays by broadcastViewModel.relays.collectAsStateWithLifecycle() + val indexerRelays by indexerViewModel.relays.collectAsStateWithLifecycle() + val proxyRelays by proxyViewModel.relays.collectAsStateWithLifecycle() Scaffold( topBar = { - TopAppBar( - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer(modifier = MinHorzSpacer) - - Text( - text = stringRes(R.string.relay_settings), - modifier = Modifier.weight(1f), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLarge, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - ) - - SaveButton( - onPost = { - kind3ViewModel.create() - dmViewModel.create() - nip65ViewModel.create() - searchViewModel.create() - localViewModel.create() - privateOutboxViewModel.create() - newNav.popBack() - }, - true, - ) - } + SavingTopBar( + titleRes = R.string.relay_settings, + onCancel = { + dmViewModel.clear() + nip65ViewModel.clear() + searchViewModel.clear() + localViewModel.clear() + privateOutboxViewModel.clear() + trustedViewModel.clear() + blockedViewModel.clear() + broadcastViewModel.clear() + indexerViewModel.clear() + proxyViewModel.clear() + newNav.popBack() }, - navigationIcon = { - Row { - Spacer(modifier = StdHorzSpacer) - CloseButton( - onPress = { - kind3ViewModel.clear() - dmViewModel.clear() - nip65ViewModel.clear() - searchViewModel.clear() - localViewModel.clear() - privateOutboxViewModel.clear() - newNav.popBack() - }, - ) - } + onPost = { + dmViewModel.create() + nip65ViewModel.create() + searchViewModel.create() + localViewModel.create() + privateOutboxViewModel.create() + trustedViewModel.create() + blockedViewModel.create() + broadcastViewModel.create() + indexerViewModel.create() + proxyViewModel.create() + newNav.popBack() }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), ) }, ) { pad -> @@ -196,7 +217,7 @@ fun MappedAllRelayListView( SettingsCategory( stringRes(R.string.public_home_section), stringRes(R.string.public_home_section_explainer), - Modifier.padding(bottom = 8.dp), + SettingsCategoryFirstModifier, ) } renderNip65HomeItems(homeFeedState, nip65ViewModel, accountViewModel, newNav) @@ -205,6 +226,7 @@ fun MappedAllRelayListView( SettingsCategory( stringRes(R.string.public_notif_section), stringRes(R.string.public_notif_section_explainer), + SettingsCategorySpacingModifier, ) } renderNip65NotifItems(notifFeedState, nip65ViewModel, accountViewModel, newNav) @@ -213,6 +235,7 @@ fun MappedAllRelayListView( SettingsCategoryWithButton( stringRes(R.string.private_inbox_section), stringRes(R.string.private_inbox_section_explainer), + SettingsCategorySpacingModifier, action = { ResetDMRelays(dmViewModel) }, @@ -224,18 +247,48 @@ fun MappedAllRelayListView( SettingsCategory( stringRes(R.string.private_outbox_section), stringRes(R.string.private_outbox_section_explainer), + SettingsCategorySpacingModifier, ) } renderPrivateOutboxItems(privateOutboxFeedState, privateOutboxViewModel, accountViewModel, newNav) + item { + SettingsCategory( + stringRes(R.string.proxy_section), + stringRes(R.string.proxy_section_explainer), + SettingsCategorySpacingModifier, + ) + } + renderProxyItems(proxyRelays, proxyViewModel, accountViewModel, newNav) + + item { + SettingsCategory( + stringRes(R.string.broadcast_section), + stringRes(R.string.broadcast_section_explainer), + SettingsCategorySpacingModifier, + ) + } + renderBroadcastItems(broadcastRelays, broadcastViewModel, accountViewModel, newNav) + + item { + SettingsCategoryWithButton( + stringRes(R.string.indexer_section), + stringRes(R.string.indexer_section_explainer), + SettingsCategorySpacingModifier, + ) { + ResetIndexerRelays(indexerViewModel) + } + } + renderIndexerItems(indexerRelays, indexerViewModel, accountViewModel, newNav) + item { SettingsCategoryWithButton( stringRes(R.string.search_section), stringRes(R.string.search_section_explainer), - action = { - ResetSearchRelays(searchViewModel) - }, - ) + SettingsCategorySpacingModifier, + ) { + ResetSearchRelays(searchViewModel) + } } renderSearchItems(searchFeedState, searchViewModel, accountViewModel, newNav) @@ -243,44 +296,38 @@ fun MappedAllRelayListView( SettingsCategory( stringRes(R.string.local_section), stringRes(R.string.local_section_explainer), + SettingsCategorySpacingModifier, ) } renderLocalItems(localFeedState, localViewModel, accountViewModel, newNav) item { - SettingsCategoryWithButton( - stringRes(R.string.kind_3_section), - stringRes(R.string.kind_3_section_description), - action = { - ResetKind3Relays(kind3ViewModel) - }, + SettingsCategory( + stringRes(R.string.trusted_section), + stringRes(R.string.trusted_section_explainer), + SettingsCategorySpacingModifier, ) } - renderKind3Items(kind3FeedState, kind3ViewModel, accountViewModel, newNav, relayToAdd) + renderTrustedItems(trustedFeedState, trustedViewModel, accountViewModel, newNav) - if (kind3Proposals.isNotEmpty()) { - item { - SettingsCategory( - stringRes(R.string.kind_3_recommended_section), - stringRes(R.string.kind_3_recommended_section_description), - ) - } - renderKind3ProposalItems(kind3Proposals, kind3ViewModel, accountViewModel, newNav) + item { + SettingsCategory( + stringRes(R.string.blocked_section), + stringRes(R.string.blocked_section_explainer), + SettingsCategorySpacingModifier, + ) } - } - } -} + renderBlockedItems(blockedFeedState, blockedViewModel, accountViewModel, newNav) -@Composable -fun ResetKind3Relays(postViewModel: Kind3RelayListViewModel) { - OutlinedButton( - onClick = { - postViewModel.deleteAll() - postViewModel.addAll(Constants.defaultRelays) - postViewModel.loadRelayDocuments() - }, - ) { - Text(stringRes(R.string.default_relays)) + item { + SettingsCategory( + stringRes(R.string.connected_section), + stringRes(R.string.connected_section_description), + SettingsCategorySpacingModifier, + ) + } + renderConnectedItems(connectedRelays, connectedViewModel, accountViewModel, newNav) + } } } @@ -301,6 +348,23 @@ fun ResetSearchRelays(postViewModel: SearchRelayListViewModel) { } } +@Composable +fun ResetIndexerRelays(postViewModel: IndexerRelayListViewModel) { + OutlinedButton( + onClick = { + postViewModel.deleteAll() + DefaultIndexerRelayList.forEach { + postViewModel.addRelay( + relaySetupInfoBuilder(it), + ) + } + postViewModel.loadRelayDocuments() + }, + ) { + Text(stringRes(R.string.default_relays)) + } +} + @Composable fun ResetDMRelays(postViewModel: DMRelayListViewModel) { OutlinedButton( @@ -320,7 +384,7 @@ fun ResetDMRelays(postViewModel: DMRelayListViewModel) { fun SettingsCategory( title: String, description: String? = null, - modifier: Modifier = Modifier.padding(top = 24.dp, bottom = 8.dp), + modifier: Modifier, ) { Column(modifier) { Text( @@ -342,8 +406,8 @@ fun SettingsCategory( fun SettingsCategoryWithButton( title: String, description: String? = null, + modifier: Modifier, action: @Composable () -> Unit, - modifier: Modifier = Modifier.padding(top = 24.dp, bottom = 8.dp), ) { Row(modifier, horizontalArrangement = RowColSpacing) { Column(modifier = Modifier.weight(1f)) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationDialog.kt deleted file mode 100644 index 3fb474081f..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationDialog.kt +++ /dev/null @@ -1,407 +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.ui.screen.loggedIn.relays - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.FeatureSetType -import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled -import com.vitorpamplona.amethyst.ui.components.ClickableEmail -import com.vitorpamplona.amethyst.ui.components.ClickableUrl -import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge -import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav -import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon -import com.vitorpamplona.amethyst.ui.note.UserCompose -import com.vitorpamplona.amethyst.ui.note.timeAgo -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer -import com.vitorpamplona.amethyst.ui.theme.StdPadding -import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer -import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache -import com.vitorpamplona.ammolite.relays.RelayStats -import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList -import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation -import kotlinx.collections.immutable.toImmutableList - -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun RelayInformationDialog( - onClose: () -> Unit, - relayBriefInfo: RelayBriefInfoCache.RelayBriefInfo, - relayInfo: Nip11RelayInformation, - accountViewModel: AccountViewModel, - nav: INav, -) { - val newNav = rememberExtendedNav(nav, onClose) - - val messages = - remember(relayBriefInfo) { - RelayStats - .get(url = relayBriefInfo.url) - .messages - .snapshot() - .values - .sortedByDescending { it.time } - .toImmutableList() - } - - Dialog( - onDismissRequest = { onClose() }, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - dismissOnClickOutside = false, - ), - ) { - SetDialogToEdgeToEdge() - Surface { - val color = - remember { - mutableStateOf(Color.Transparent) - } - - val context = LocalContext.current - - LazyColumn( - modifier = - Modifier - .padding(10.dp) - .fillMaxSize(), - ) { - item { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - CloseButton(onPress = { onClose() }) - } - } - item { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = StdPadding.fillMaxWidth(), - ) { - Column { - RenderRelayIcon( - displayUrl = relayBriefInfo.displayUrl, - iconUrl = relayInfo.icon ?: relayBriefInfo.favIcon, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, - loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, - RelayStats.get(url = relayBriefInfo.url).pingInMs, - iconModifier = MaterialTheme.colorScheme.largeRelayIconModifier, - ) - } - - Spacer(modifier = DoubleHorzSpacer) - - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Title(relayInfo.name?.trim() ?: "") - SubtitleContent(relayInfo.description?.trim() ?: "") - } - } - } - item { - Section(stringRes(R.string.owner)) - - relayInfo.pubkey?.let { - DisplayOwnerInformation(it, accountViewModel, newNav) - } - } - item { - Section(stringRes(R.string.software)) - - DisplaySoftwareInformation(relayInfo) - - Section(stringRes(R.string.version)) - - SectionContent(relayInfo.version ?: "") - } - item { - Section(stringRes(R.string.contact)) - - Box(modifier = Modifier.padding(start = 10.dp)) { - relayInfo.contact?.let { - if (it.startsWith("https:")) { - ClickableUrl(urlText = it, url = it) - } else if (it.startsWith("mailto:") || it.contains('@')) { - ClickableEmail(it) - } else { - SectionContent(it) - } - } - } - } - item { - Section(stringRes(R.string.supports)) - - DisplaySupportedNips(relayInfo) - } - item { - relayInfo.fees?.admission?.let { - if (it.isNotEmpty()) { - Section(stringRes(R.string.admission_fees)) - - it.forEach { item -> SectionContent("${item.amount?.div(1000) ?: 0} sats") } - } - } - - relayInfo.payments_url?.let { - Section(stringRes(R.string.payments_url)) - - Box(modifier = Modifier.padding(start = 10.dp)) { - ClickableUrl( - urlText = it, - url = it, - ) - } - } - } - item { - relayInfo.limitation?.let { - Section(stringRes(R.string.limitations)) - val authRequiredText = - if (it.auth_required ?: false) stringRes(R.string.yes) else stringRes(R.string.no) - - val paymentRequiredText = - if (it.payment_required ?: false) stringRes(R.string.yes) else stringRes(R.string.no) - - val restrictedWritesText = - if (it.restricted_writes ?: false) stringRes(R.string.yes) else stringRes(R.string.no) - - Column { - SectionContent( - "${stringRes(R.string.message_length)}: ${it.max_message_length ?: 0}", - ) - SectionContent( - "${stringRes(R.string.subscriptions)}: ${it.max_subscriptions ?: 0}", - ) - SectionContent("${stringRes(R.string.filters)}: ${it.max_filters ?: 0}") - SectionContent( - "${stringRes(R.string.subscription_id_length)}: ${it.max_subid_length ?: 0}", - ) - SectionContent("${stringRes(R.string.minimum_prefix)}: ${it.min_prefix ?: 0}") - SectionContent( - "${stringRes(R.string.maximum_event_tags)}: ${it.max_event_tags ?: 0}", - ) - SectionContent( - "${stringRes(R.string.content_length)}: ${it.max_content_length ?: 0}", - ) - SectionContent( - "${stringRes(R.string.max_limit)}: ${it.max_limit ?: 0}", - ) - SectionContent("${stringRes(R.string.minimum_pow)}: ${it.min_pow_difficulty ?: 0}") - SectionContent("${stringRes(R.string.auth)}: $authRequiredText") - SectionContent("${stringRes(R.string.payment)}: $paymentRequiredText") - SectionContent("${stringRes(R.string.restricted_writes)}: $restrictedWritesText") - } - } - } - item { - relayInfo.relay_countries?.let { - Section(stringRes(R.string.countries)) - - FlowRow { it.forEach { item -> SectionContent(item) } } - } - } - item { - relayInfo.language_tags?.let { - Section(stringRes(R.string.languages)) - - FlowRow { it.forEach { item -> SectionContent(item) } } - } - } - item { - relayInfo.tags?.let { - Section(stringRes(R.string.tags)) - - FlowRow { it.forEach { item -> SectionContent(item) } } - } - } - item { - relayInfo.posting_policy?.let { - Section(stringRes(R.string.posting_policy)) - - Box(Modifier.padding(10.dp)) { - ClickableUrl( - it, - it, - ) - } - } - } - - item { - Section(stringRes(R.string.relay_error_messages)) - } - - items(messages) { msg -> - Row { - SelectionContainer { - TranslatableRichTextViewer( - content = - remember { - "${timeAgo(msg.time, context)}, ${msg.type.name}: ${msg.message}" - }, - canPreview = false, - quotesLeft = 0, - modifier = Modifier.fillMaxWidth(), - tags = EmptyTagList, - backgroundColor = color, - id = msg.hashCode().toString(), - accountViewModel = accountViewModel, - nav = newNav, - ) - } - } - - Spacer(modifier = StdVertSpacer) - } - } - } - } -} - -@Composable -@OptIn(ExperimentalLayoutApi::class) -private fun DisplaySupportedNips(relayInfo: Nip11RelayInformation) { - FlowRow { - relayInfo.supported_nips?.forEach { item -> - val text = item.toString().padStart(2, '0') - Box(Modifier.padding(10.dp)) { - ClickableUrl( - urlText = text, - url = "https://github.com/nostr-protocol/nips/blob/master/$text.md", - ) - } - } - - relayInfo.supported_nip_extensions?.forEach { item -> - val text = item.padStart(2, '0') - Box(Modifier.padding(10.dp)) { - ClickableUrl( - urlText = text, - url = "https://github.com/nostr-protocol/nips/blob/master/$text.md", - ) - } - } - } -} - -@Composable -private fun DisplaySoftwareInformation(relayInfo: Nip11RelayInformation) { - val url = (relayInfo.software ?: "").replace("git+", "") - Box(modifier = Modifier.padding(start = 10.dp)) { - ClickableUrl( - urlText = url, - url = url, - ) - } -} - -@Composable -private fun DisplayOwnerInformation( - userHex: String, - accountViewModel: AccountViewModel, - nav: INav, -) { - LoadUser(baseUserHex = userHex, accountViewModel) { - CrossfadeIfEnabled(it, accountViewModel = accountViewModel) { - if (it != null) { - UserCompose( - baseUser = it, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } -} - -@Composable -fun Title(text: String) { - Text( - text = text, - fontWeight = FontWeight.Bold, - fontSize = 24.sp, - ) -} - -@Composable -fun SubtitleContent(text: String) { - Text( - text = text, - ) -} - -@Composable -fun Section(text: String) { - Spacer(modifier = DoubleVertSpacer) - Text( - text = text, - fontWeight = FontWeight.Bold, - fontSize = 20.sp, - ) - Spacer(modifier = DoubleVertSpacer) -} - -@Composable -fun SectionContent(text: String) { - Text( - modifier = Modifier.padding(start = 10.dp), - text = text, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt new file mode 100644 index 0000000000..708e6c4db5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -0,0 +1,450 @@ +/** + * 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.ui.screen.loggedIn.relays + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.FeatureSetType +import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled +import com.vitorpamplona.amethyst.ui.components.ClickableEmail +import com.vitorpamplona.amethyst.ui.components.ClickableUrl +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon +import com.vitorpamplona.amethyst.ui.note.UserCompose +import com.vitorpamplona.amethyst.ui.note.timeAgo +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.BackButton +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer +import com.vitorpamplona.amethyst.ui.theme.LargeRelayIconModifier +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.StdPadding +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayDebugMessage +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun RelayInformationScreen( + relayUrl: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + RelayUrlNormalizer.normalizeOrNull(relayUrl)?.let { + RelayInformationScreen( + relay = it, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RelayInformationScreen( + relay: NormalizedRelayUrl, + accountViewModel: AccountViewModel, + nav: INav, +) { + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + actions = {}, + title = { + Text(relay.displayUrl()) + }, + navigationIcon = { + Row { + Spacer(modifier = StdHorzSpacer) + BackButton( + onPress = nav::popBack, + ) + } + }, + ) + }, + ) { pad -> + val relayInfo by loadRelayInfo(relay, accountViewModel) + + val messages = + remember(relay) { + RelayStats + .get(url = relay) + .messages + .snapshot() + .values + .sortedByDescending { it.time } + .toImmutableList() + } + + LazyColumn( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .padding(bottom = Size10dp, start = Size10dp, end = Size10dp) + .fillMaxSize(), + ) { + item { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = StdPadding.fillMaxWidth(), + ) { + Column { + RenderRelayIcon( + displayUrl = relay.displayUrl(), + iconUrl = relayInfo.icon, + loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, + RelayStats.get(relay).pingInMs, + iconModifier = LargeRelayIconModifier, + ) + } + + Spacer(modifier = DoubleHorzSpacer) + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Title(relayInfo.name?.trim() ?: "") + Spacer(modifier = HalfVertSpacer) + SubtitleContent(relay.url) + } + } + } + item { + Section(stringRes(R.string.description)) + + SectionContent(relayInfo.description?.trim() ?: stringRes(R.string.no_description)) + } + relayInfo.pubkey?.let { + item { + Section(stringRes(R.string.owner)) + DisplayOwnerInformation(it, accountViewModel, nav) + } + } + relayInfo.contact?.let { + item { + Section(stringRes(R.string.contact)) + + Box(modifier = Modifier.padding(start = 10.dp)) { + if (it.startsWith("https:")) { + ClickableUrl(urlText = it, url = it) + } else if (it.startsWith("mailto:") || it.contains('@')) { + ClickableEmail(it) + } else { + SectionContent(it) + } + } + } + } + relayInfo.software?.let { + item { + Section(stringRes(R.string.software)) + + DisplaySoftwareInformation(it) + + Section(stringRes(R.string.version)) + + SectionContent(relayInfo.version ?: "") + } + } + relayInfo.supported_nips?.let { + if (it.isNotEmpty()) { + item { + Section(stringRes(R.string.supports)) + + DisplaySupportedNips(it, relayInfo.supported_nip_extensions) + } + } + } + relayInfo.fees?.admission?.let { + item { + if (it.isNotEmpty()) { + Section(stringRes(R.string.admission_fees)) + + it.forEach { item -> SectionContent("${item.amount?.div(1000) ?: 0} sats") } + } + } + } + + relayInfo.payments_url?.let { + item { + Section(stringRes(R.string.payments_url)) + + Box(modifier = Modifier.padding(start = 10.dp)) { + ClickableUrl( + urlText = it, + url = it, + ) + } + } + } + + relayInfo.limitation?.let { + item { + Section(stringRes(R.string.limitations)) + val authRequiredText = + if (it.auth_required ?: false) stringRes(R.string.yes) else stringRes(R.string.no) + + val paymentRequiredText = + if (it.payment_required ?: false) stringRes(R.string.yes) else stringRes(R.string.no) + + val restrictedWritesText = + if (it.restricted_writes ?: false) stringRes(R.string.yes) else stringRes(R.string.no) + + Column { + SectionContent( + "${stringRes(R.string.message_length)}: ${it.max_message_length ?: 0}", + ) + SectionContent( + "${stringRes(R.string.subscriptions)}: ${it.max_subscriptions ?: 0}", + ) + SectionContent("${stringRes(R.string.filters)}: ${it.max_filters ?: 0}") + SectionContent( + "${stringRes(R.string.subscription_id_length)}: ${it.max_subid_length ?: 0}", + ) + SectionContent("${stringRes(R.string.minimum_prefix)}: ${it.min_prefix ?: 0}") + SectionContent( + "${stringRes(R.string.maximum_event_tags)}: ${it.max_event_tags ?: 0}", + ) + SectionContent( + "${stringRes(R.string.content_length)}: ${it.max_content_length ?: 0}", + ) + SectionContent( + "${stringRes(R.string.max_limit)}: ${it.max_limit ?: 0}", + ) + SectionContent("${stringRes(R.string.minimum_pow)}: ${it.min_pow_difficulty ?: 0}") + SectionContent("${stringRes(R.string.auth)}: $authRequiredText") + SectionContent("${stringRes(R.string.payment)}: $paymentRequiredText") + SectionContent("${stringRes(R.string.restricted_writes)}: $restrictedWritesText") + } + } + } + relayInfo.relay_countries?.let { + item { + Section(stringRes(R.string.countries)) + + FlowRow { it.forEach { item -> SectionContent(item) } } + } + } + relayInfo.language_tags?.let { + item { + Section(stringRes(R.string.languages)) + + FlowRow { it.forEach { item -> SectionContent(item) } } + } + } + relayInfo.tags?.let { + item { + Section(stringRes(R.string.tags)) + + FlowRow { it.forEach { item -> SectionContent(item) } } + } + } + relayInfo.posting_policy?.let { + item { + Section(stringRes(R.string.posting_policy)) + + Box(Modifier.padding(10.dp)) { + ClickableUrl( + it, + it, + ) + } + } + } + + item { + Section(stringRes(R.string.relay_error_messages)) + } + + items(messages) { msg -> + Row { + RenderDebugMessage(msg, accountViewModel, nav) + } + + Spacer(modifier = StdVertSpacer) + } + } + } +} + +@Composable +private fun RenderDebugMessage( + msg: RelayDebugMessage, + accountViewModel: AccountViewModel, + newNav: INav, +) { + SelectionContainer { + val context = LocalContext.current + val color = + remember { + mutableStateOf(Color.Transparent) + } + TranslatableRichTextViewer( + content = + remember { + "${timeAgo(msg.time, context)}, ${msg.type.name}: ${msg.message}" + }, + canPreview = false, + quotesLeft = 0, + modifier = Modifier.fillMaxWidth(), + tags = EmptyTagList, + backgroundColor = color, + id = msg.hashCode().toString(), + accountViewModel = accountViewModel, + nav = newNav, + ) + } +} + +@Composable +@OptIn(ExperimentalLayoutApi::class) +private fun DisplaySupportedNips( + supportedNips: List, + supportedNipExtensions: List?, +) { + FlowRow { + supportedNips.forEach { item -> + val text = item.toString().padStart(2, '0') + Box(Modifier.padding(10.dp)) { + ClickableUrl( + urlText = text, + url = "https://github.com/nostr-protocol/nips/blob/master/$text.md", + ) + } + } + + supportedNipExtensions?.forEach { item -> + val text = item.padStart(2, '0') + Box(Modifier.padding(10.dp)) { + ClickableUrl( + urlText = text, + url = "https://github.com/nostr-protocol/nips/blob/master/$text.md", + ) + } + } + } +} + +@Composable +private fun DisplaySoftwareInformation(software: String) { + val url = software.replace("git+", "") + Box(modifier = Modifier.padding(start = 10.dp)) { + ClickableUrl( + urlText = url, + url = url, + ) + } +} + +@Composable +private fun DisplayOwnerInformation( + userHex: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + LoadUser(baseUserHex = userHex, accountViewModel) { + CrossfadeIfEnabled(it, accountViewModel = accountViewModel) { + if (it != null) { + UserCompose( + baseUser = it, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } +} + +@Composable +fun Title(text: String) { + Text( + text = text, + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + ) +} + +@Composable +fun SubtitleContent(text: String) { + Text( + text = text, + ) +} + +@Composable +fun Section(text: String) { + Spacer(modifier = DoubleVertSpacer) + Text( + text = text, + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + ) + Spacer(modifier = DoubleVertSpacer) +} + +@Composable +fun SectionContent(text: String) { + Text( + modifier = Modifier.padding(start = 10.dp), + text = text, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListView.kt new file mode 100644 index 0000000000..3e3c1eaf58 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListView.kt @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.blocked + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer + +@Composable +fun BlockedRelayList( + postViewModel: BlockedRelayListViewModel, + accountViewModel: AccountViewModel, + onClose: () -> Unit, + nav: INav, +) { + val feedState by postViewModel.relays.collectAsStateWithLifecycle() + + val newNav = rememberExtendedNav(nav, onClose) + + Row(verticalAlignment = Alignment.CenterVertically) { + LazyColumn( + contentPadding = FeedPadding, + ) { + renderBlockedItems(feedState, postViewModel, accountViewModel, newNav) + } + } +} + +fun LazyListScope.renderBlockedItems( + feedState: List, + postViewModel: BlockedRelayListViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + itemsIndexed(feedState, key = { _, item -> "Trusted" + item.relay }) { index, item -> + BasicRelaySetupInfoDialog( + item, + onDelete = { postViewModel.deleteRelay(item) }, + accountViewModel = accountViewModel, + nav, + ) + } + + item { + Spacer(modifier = StdVertSpacer) + RelayUrlEditField { postViewModel.addRelay(relaySetupInfoBuilder(it)) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListViewModel.kt new file mode 100644 index 0000000000..77f93513b6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListViewModel.kt @@ -0,0 +1,34 @@ +/** + * 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.ui.screen.loggedIn.relays.blocked + +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class BlockedRelayListViewModel : BasicRelaySetupInfoModel() { + override fun getRelayList(): List? = + account.blockedRelayList.flow.value + .toList() + + override suspend fun saveRelayList(urlList: List) { + account.saveBlockedRelayList(urlList) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListView.kt new file mode 100644 index 0000000000..5fd691e581 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListView.kt @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.broadcast + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer + +@Composable +fun BroadcastRelayList( + postViewModel: BroadcastRelayListViewModel, + accountViewModel: AccountViewModel, + onClose: () -> Unit, + nav: INav, +) { + val feedState by postViewModel.relays.collectAsStateWithLifecycle() + + val newNav = rememberExtendedNav(nav, onClose) + + Row(verticalAlignment = Alignment.CenterVertically) { + LazyColumn( + contentPadding = FeedPadding, + ) { + renderBroadcastItems(feedState, postViewModel, accountViewModel, newNav) + } + } +} + +fun LazyListScope.renderBroadcastItems( + feedState: List, + postViewModel: BroadcastRelayListViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + itemsIndexed(feedState, key = { _, item -> "Broadcast" + item.relay }) { index, item -> + BasicRelaySetupInfoDialog( + item, + onDelete = { postViewModel.deleteRelay(item) }, + accountViewModel = accountViewModel, + nav, + ) + } + + item { + Spacer(modifier = StdVertSpacer) + RelayUrlEditField { postViewModel.addRelay(relaySetupInfoBuilder(it)) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListViewModel.kt new file mode 100644 index 0000000000..fb36e2080e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListViewModel.kt @@ -0,0 +1,35 @@ +/** + * 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.ui.screen.loggedIn.relays.broadcast + +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import java.util.stream.Collectors.toList + +class BroadcastRelayListViewModel : BasicRelaySetupInfoModel() { + override fun getRelayList(): List? = + account.broadcastRelayList.flow.value + .toList() + + override suspend fun saveRelayList(urlList: List) { + account.saveBroadcastRelayList(urlList) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfo.kt index 7d29082f23..3fac4efd4a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfo.kt @@ -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,24 +21,24 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common import androidx.compose.runtime.Immutable -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache -import com.vitorpamplona.ammolite.relays.RelayStats -import com.vitorpamplona.quartz.nip01Core.relay.RelayStat -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Immutable data class BasicRelaySetupInfo( - val url: String, + val relay: NormalizedRelayUrl, val relayStat: RelayStat, val paidRelay: Boolean = false, -) { - val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url) -} + val forcesTor: Boolean = false, +) -fun relaySetupInfoBuilder(url: String): BasicRelaySetupInfo { - val normalized = RelayUrlFormatter.normalize(url) - return BasicRelaySetupInfo( - normalized, - RelayStats.get(normalized), +fun relaySetupInfoBuilder( + normalized: NormalizedRelayUrl, + forcesTor: Boolean = false, +): BasicRelaySetupInfo = + BasicRelaySetupInfo( + relay = normalized, + relayStat = RelayStats.get(normalized), + forcesTor = forcesTor, ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt index d0fee4c978..98da2d9219 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt @@ -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,22 +27,22 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString -import com.vitorpamplona.amethyst.service.Nip11CachedRetriever import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding +import com.vitorpamplona.amethyst.ui.theme.LargeRelayIconModifier import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChatMaxWidth -import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl @OptIn(ExperimentalFoundationApi::class) @Composable @@ -50,7 +50,7 @@ fun BasicRelaySetupInfoClickableRow( item: BasicRelaySetupInfo, loadProfilePicture: Boolean, loadRobohash: Boolean, - onDelete: (BasicRelaySetupInfo) -> Unit, + onDelete: ((BasicRelaySetupInfo) -> Unit)?, onClick: () -> Unit, accountViewModel: AccountViewModel, ) { @@ -61,7 +61,7 @@ fun BasicRelaySetupInfoClickableRow( .combinedClickable( onClick = onClick, onLongClick = { - clipboardManager.setText(AnnotatedString(item.briefInfo.url)) + clipboardManager.setText(AnnotatedString(item.relay.url)) }, ), ) { @@ -69,18 +69,15 @@ fun BasicRelaySetupInfoClickableRow( verticalAlignment = Alignment.CenterVertically, modifier = HalfVertPadding, ) { - val iconUrlFromRelayInfoDoc = - remember(item) { - Nip11CachedRetriever.getFromCache(item.url)?.icon - } + val iconUrlFromRelayInfoDoc by loadRelayInfo(item.relay, accountViewModel) RenderRelayIcon( - item.briefInfo.displayUrl, - iconUrlFromRelayInfoDoc ?: item.briefInfo.favIcon, + iconUrlFromRelayInfoDoc.id ?: item.relay.displayUrl(), + iconUrlFromRelayInfoDoc.icon, loadProfilePicture, loadRobohash, item.relayStat.pingInMs, - MaterialTheme.colorScheme.largeRelayIconModifier, + LargeRelayIconModifier, ) Spacer(modifier = HalfHorzPadding) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt index 66b55a623a..e5b2b7b4aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt @@ -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,39 +22,20 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.FeatureSetType -import com.vitorpamplona.amethyst.service.Nip11Retriever -import com.vitorpamplona.amethyst.ui.actions.RelayInfoDialog -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationDialog -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo @Composable fun BasicRelaySetupInfoDialog( item: BasicRelaySetupInfo, - onDelete: (BasicRelaySetupInfo) -> Unit, + onDelete: ((BasicRelaySetupInfo) -> Unit)?, accountViewModel: AccountViewModel, nav: INav, ) { - var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) } - val context = LocalContext.current - - relayInfo?.let { - RelayInformationDialog( - onClose = { relayInfo = null }, - relayInfo = it.relayInfo, - relayBriefInfo = it.relayBriefInfo, - accountViewModel = accountViewModel, - nav = nav, - ) - } + val relayInfo by loadRelayInfo(item.relay, accountViewModel) BasicRelaySetupInfoClickableRow( item = item, @@ -62,54 +43,6 @@ fun BasicRelaySetupInfoDialog( loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, onDelete = onDelete, accountViewModel = accountViewModel, - onClick = { - accountViewModel.retrieveRelayDocument( - item.url, - onInfo = { - relayInfo = RelayInfoDialog(RelayBriefInfoCache.RelayBriefInfo(item.url), it) - }, - onError = { url, errorCode, exceptionMessage -> - val msg = - when (errorCode) { - Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - - Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - - Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - - Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - } - - accountViewModel.toastManager.toast( - stringRes(context, R.string.unable_to_download_relay_document), - msg, - ) - }, - ) - }, + onClick = { nav.nav(Route.RelayInfo(item.relay.url)) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt index eb8e2f998e..9f84c96d5a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt @@ -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,6 +26,8 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.Nip11CachedRetriever import com.vitorpamplona.amethyst.service.replace +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -33,6 +35,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch abstract class BasicRelaySetupInfoModel : ViewModel() { + lateinit var accountViewModel: AccountViewModel lateinit var account: Account private val _relays = MutableStateFlow>(emptyList()) @@ -40,20 +43,24 @@ abstract class BasicRelaySetupInfoModel : ViewModel() { var hasModified = false - fun load(account: Account) { - this.account = account + fun init(accountViewModel: AccountViewModel) { + this.accountViewModel = accountViewModel + this.account = accountViewModel.account + } + + fun load() { clear() loadRelayDocuments() } - abstract fun getRelayList(): List? + abstract fun getRelayList(): List? - abstract fun saveRelayList(urlList: List) + abstract suspend fun saveRelayList(urlList: List) fun create() { if (hasModified) { - viewModelScope.launch(Dispatchers.IO) { - saveRelayList(_relays.value.map { it.url }) + accountViewModel.runIOCatching { + saveRelayList(_relays.value.map { it.relay }) clear() } } @@ -63,8 +70,10 @@ abstract class BasicRelaySetupInfoModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { _relays.value.forEach { item -> Nip11CachedRetriever.loadRelayInfo( - dirtyUrl = item.url, - okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForDirty(item.url)) }, + relay = item.relay, + okHttpClient = { + Amethyst.instance.okHttpClients.getHttpClient(account.torRelayState.shouldUseTorForClean(item.relay)) + }, onInfo = { togglePaidRelay(item, it.limitation?.payment_required ?: false) }, @@ -75,20 +84,25 @@ abstract class BasicRelaySetupInfoModel : ViewModel() { } fun clear() { - var hasModified = false _relays.update { val relayList = getRelayList() ?: emptyList() relayList - .map { relaySetupInfoBuilder(it) } - .distinctBy { it.url } + .map { + relaySetupInfoBuilder( + normalized = it, + forcesTor = + account.torRelayState.flow.value + .useTor(it), + ) + }.distinctBy { it.relay } .sortedBy { it.relayStat.receivedBytes } .reversed() } } fun addRelay(relay: BasicRelaySetupInfo) { - if (relays.value.any { it.url == relay.url }) return + if (relays.value.any { it.relay == relay.relay }) return _relays.update { it.plus(relay) } hasModified = true diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayNameAndRemoveButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayNameAndRemoveButton.kt index 8b34ea3d5e..a19a212359 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayNameAndRemoveButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayNameAndRemoveButton.kt @@ -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 @@ -40,28 +40,30 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.WarningColor +import com.vitorpamplona.amethyst.ui.theme.LightRedColor import com.vitorpamplona.amethyst.ui.theme.allGoodColor +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl @OptIn(ExperimentalFoundationApi::class) @Composable fun RelayNameAndRemoveButton( item: BasicRelaySetupInfo, onClick: () -> Unit, - onDelete: (BasicRelaySetupInfo) -> Unit, + onDelete: ((BasicRelaySetupInfo) -> Unit)?, modifier: Modifier, ) { val clipboardManager = LocalClipboardManager.current Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Text( - text = item.briefInfo.displayUrl, + text = item.relay.displayUrl(), modifier = Modifier.combinedClickable( onClick = onClick, onLongClick = { - clipboardManager.setText(AnnotatedString(item.briefInfo.url)) + clipboardManager.setText(AnnotatedString(item.relay.url)) }, ), maxLines = 1, @@ -71,29 +73,43 @@ fun RelayNameAndRemoveButton( if (item.paidRelay) { Icon( imageVector = Icons.Default.Paid, - null, + contentDescription = stringRes(id = R.string.paid_relay), modifier = Modifier - .padding(start = 5.dp, top = 1.dp) + .padding(start = 5.dp) + .size(14.dp), + tint = MaterialTheme.colorScheme.allGoodColor, + ) + } + + if (item.forcesTor) { + Icon( + painter = painterRes(R.drawable.ic_tor, 2), + contentDescription = stringRes(id = R.string.tor_relay), + modifier = + Modifier + .padding(start = 5.dp) .size(14.dp), tint = MaterialTheme.colorScheme.allGoodColor, ) } } - IconButton( - modifier = Modifier.size(30.dp), - onClick = { onDelete(item) }, - ) { - Icon( - imageVector = Icons.Default.Cancel, - contentDescription = stringRes(id = R.string.remove), - modifier = - Modifier - .padding(start = 10.dp) - .size(15.dp), - tint = WarningColor, - ) + if (onDelete != null) { + IconButton( + modifier = Modifier.size(30.dp), + onClick = { onDelete(item) }, + ) { + Icon( + imageVector = Icons.Default.Cancel, + contentDescription = stringRes(id = R.string.remove), + modifier = + Modifier + .padding(start = 10.dp) + .size(15.dp), + tint = LightRedColor, + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayStatusRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayStatusRow.kt index 5170e7f285..ebc8a42832 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayStatusRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayStatusRow.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt index 4633e769a2..9eadc8c941 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt @@ -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 @@ -47,6 +47,8 @@ import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @Preview @Composable @@ -57,7 +59,7 @@ fun RelayUrlEditFieldPreview() { } @Composable -fun RelayUrlEditField(onNewRelay: (String) -> Unit) { +fun RelayUrlEditField(onNewRelay: (NormalizedRelayUrl) -> Unit) { var url by remember { mutableStateOf("") } Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Size10dp)) { @@ -85,8 +87,11 @@ fun RelayUrlEditField(onNewRelay: (String) -> Unit) { KeyboardActions( onGo = { if (url.isNotBlank() && url != "/") { - onNewRelay(url) - url = "" + val relay = RelayUrlNormalizer.normalizeOrNull(url) + if (relay != null) { + onNewRelay(relay) + url = "" + } } }, ), @@ -95,8 +100,11 @@ fun RelayUrlEditField(onNewRelay: (String) -> Unit) { Button( onClick = { if (url.isNotBlank() && url != "/") { - onNewRelay(url) - url = "" + val relay = RelayUrlNormalizer.normalizeOrNull(url) + if (relay != null) { + onNewRelay(relay) + url = "" + } } }, shape = ButtonBorder, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListView.kt new file mode 100644 index 0000000000..5b27d5890d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListView.kt @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.theme.FeedPadding + +@Composable +fun ConnectedRelayList( + postViewModel: ConnectedRelayListViewModel, + accountViewModel: AccountViewModel, + onClose: () -> Unit, + nav: INav, +) { + val newNav = rememberExtendedNav(nav, onClose) + val feedState by postViewModel.relays.collectAsStateWithLifecycle() + + Row(verticalAlignment = Alignment.CenterVertically) { + LazyColumn( + contentPadding = FeedPadding, + ) { + renderConnectedItems(feedState, postViewModel, accountViewModel, newNav) + } + } +} + +fun LazyListScope.renderConnectedItems( + feedState: List, + postViewModel: ConnectedRelayListViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + itemsIndexed(feedState, key = { _, item -> "Connected" + item.relay }) { index, item -> + BasicRelaySetupInfoDialog( + item, + onDelete = null, + accountViewModel = accountViewModel, + nav, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt new file mode 100644 index 0000000000..9759533876 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt @@ -0,0 +1,35 @@ +/** + * 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.ui.screen.loggedIn.relays.connected + +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class ConnectedRelayListViewModel : BasicRelaySetupInfoModel() { + override fun getRelayList(): List = + account.client + .relayStatusFlow() + .value.available + .sorted() + + override suspend fun saveRelayList(urlList: List) { + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/AddDMRelayListDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/AddDMRelayListDialog.kt index 5cf3c83af9..3f8df123f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/AddDMRelayListDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/AddDMRelayListDialog.kt @@ -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,9 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api @@ -32,11 +30,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @@ -45,13 +40,11 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.DefaultDMRelayList import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.imageModifier @@ -64,7 +57,11 @@ fun AddDMRelayListDialog( ) { val postViewModel: DMRelayListViewModel = viewModel() - LaunchedEffect(Unit) { postViewModel.load(accountViewModel.account) } + postViewModel.init(accountViewModel) + + LaunchedEffect(postViewModel, accountViewModel.account) { + postViewModel.load() + } Dialog( onDismissRequest = onClose, @@ -73,39 +70,16 @@ fun AddDMRelayListDialog( SetDialogToEdgeToEdge() Scaffold( topBar = { - TopAppBar( - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer(modifier = StdHorzSpacer) - - Text(stringRes(R.string.dm_relays_title)) - - SaveButton( - onPost = { - postViewModel.create() - onClose() - }, - true, - ) - } + SavingTopBar( + titleRes = R.string.dm_relays_title, + onCancel = { + postViewModel.clear() + onClose() }, - navigationIcon = { - Spacer(modifier = StdHorzSpacer) - CloseButton( - onPress = { - postViewModel.clear() - onClose() - }, - ) + onPost = { + postViewModel.create() + onClose() }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), ) }, ) { pad -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListView.kt index 353ebc0333..79e66ae290 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListView.kt @@ -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 @@ -29,8 +29,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog @@ -64,7 +64,7 @@ fun LazyListScope.renderDMItems( accountViewModel: AccountViewModel, nav: INav, ) { - itemsIndexed(feedState, key = { _, item -> "DM" + item.url }) { index, item -> + itemsIndexed(feedState, key = { _, item -> "DM" + item.relay }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt index 66114e1132..01b31c931a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt @@ -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,11 +21,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class DMRelayListViewModel : BasicRelaySetupInfoModel() { - override fun getRelayList(): List? = account.getDMRelayList()?.relays() + override fun getRelayList(): List? = account.dmRelayList.getDMRelayList()?.relays() - override fun saveRelayList(urlList: List) { + override suspend fun saveRelayList(urlList: List) { account.saveDMRelayList(urlList) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListView.kt new file mode 100644 index 0000000000..59c2671274 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListView.kt @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.indexer + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer + +@Composable +fun IndexerRelayList( + postViewModel: IndexerRelayListViewModel, + accountViewModel: AccountViewModel, + onClose: () -> Unit, + nav: INav, +) { + val feedState by postViewModel.relays.collectAsStateWithLifecycle() + + val newNav = rememberExtendedNav(nav, onClose) + + Row(verticalAlignment = Alignment.CenterVertically) { + LazyColumn( + contentPadding = FeedPadding, + ) { + renderIndexerItems(feedState, postViewModel, accountViewModel, newNav) + } + } +} + +fun LazyListScope.renderIndexerItems( + feedState: List, + postViewModel: IndexerRelayListViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + itemsIndexed(feedState, key = { _, item -> "Indexer" + item.relay }) { index, item -> + BasicRelaySetupInfoDialog( + item, + onDelete = { postViewModel.deleteRelay(item) }, + accountViewModel = accountViewModel, + nav, + ) + } + + item { + Spacer(modifier = StdVertSpacer) + RelayUrlEditField { postViewModel.addRelay(relaySetupInfoBuilder(it)) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt new file mode 100644 index 0000000000..8bbb81fa2f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt @@ -0,0 +1,34 @@ +/** + * 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.ui.screen.loggedIn.relays.indexer + +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class IndexerRelayListViewModel : BasicRelaySetupInfoModel() { + override fun getRelayList(): List? = + account.indexerRelayList.flow.value + .toList() + + override suspend fun saveRelayList(urlList: List) { + account.saveIndexerRelayList(urlList) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3RelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3RelayListView.kt deleted file mode 100644 index 6e863b1a49..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3RelayListView.kt +++ /dev/null @@ -1,827 +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.ui.screen.loggedIn.relays.kind3 - -import android.widget.Toast -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Cancel -import androidx.compose.material.icons.filled.DeleteSweep -import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.Groups -import androidx.compose.material.icons.filled.Paid -import androidx.compose.material.icons.filled.Public -import androidx.compose.material.icons.filled.SyncProblem -import androidx.compose.material.icons.filled.Upload -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.FeatureSetType -import com.vitorpamplona.amethyst.service.Nip11CachedRetriever -import com.vitorpamplona.amethyst.service.Nip11Retriever -import com.vitorpamplona.amethyst.service.countToHumanReadable -import com.vitorpamplona.amethyst.service.countToHumanReadableBytes -import com.vitorpamplona.amethyst.ui.actions.RelayInfoDialog -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav -import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationDialog -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.recommendations.Kind3RelayProposalSetupInfo -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.recommendations.Kind3RelaySetupInfoProposalDialog -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.ButtonBorder -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.FeedPadding -import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding -import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding -import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChatMaxWidth -import com.vitorpamplona.amethyst.ui.theme.Size30Modifier -import com.vitorpamplona.amethyst.ui.theme.Size35dp -import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer -import com.vitorpamplona.amethyst.ui.theme.WarningColor -import com.vitorpamplona.amethyst.ui.theme.allGoodColor -import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier -import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.amethyst.ui.theme.warningColor -import com.vitorpamplona.ammolite.relays.Constants.activeTypesGlobalChats -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache -import com.vitorpamplona.ammolite.relays.RelayStats -import com.vitorpamplona.quartz.nip01Core.relay.RelayStat -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter -import kotlinx.coroutines.launch - -@Composable -fun Kind3RelayListView( - feedState: List, - postViewModel: Kind3RelayListViewModel, - accountViewModel: AccountViewModel, - onClose: () -> Unit, - nav: INav, - relayToAdd: String, -) { - val newNav = rememberExtendedNav(nav, onClose) - - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - LazyColumn(contentPadding = FeedPadding) { - renderKind3Items(feedState, postViewModel, accountViewModel, newNav, relayToAdd) - } - } -} - -fun LazyListScope.renderKind3Items( - feedState: List, - postViewModel: Kind3RelayListViewModel, - accountViewModel: AccountViewModel, - nav: INav, - relayToAdd: String, -) { - itemsIndexed(feedState, key = { _, item -> "kind3" + item.url }) { index, item -> - LoadRelayInfo( - item, - onToggleDownload = { postViewModel.toggleDownload(it) }, - onToggleUpload = { postViewModel.toggleUpload(it) }, - onToggleFollows = { postViewModel.toggleFollows(it) }, - onTogglePrivateDMs = { postViewModel.toggleMessages(it) }, - onTogglePublicChats = { postViewModel.togglePublicChats(it) }, - onToggleGlobal = { postViewModel.toggleGlobal(it) }, - onToggleSearch = { postViewModel.toggleSearch(it) }, - onDelete = { postViewModel.deleteRelay(it) }, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - item { - Spacer(modifier = StdVertSpacer) - Kind3RelayEditBox(relayToAdd) { postViewModel.addRelay(it) } - } -} - -fun LazyListScope.renderKind3ProposalItems( - feedState: List, - postViewModel: Kind3RelayListViewModel, - accountViewModel: AccountViewModel, - nav: INav, -) { - itemsIndexed(feedState, key = { _, item -> "kind3proposal" + item.url }) { index, item -> - Kind3RelaySetupInfoProposalDialog( - item = item, - onAdd = { - postViewModel.addRelay(item) - }, - accountViewModel = accountViewModel, - nav = nav, - ) - HorizontalDivider( - thickness = DividerThickness, - ) - } -} - -@Preview -@Composable -fun ServerConfigPreview() { - ClickableRelayItem( - loadProfilePicture = true, - loadRobohash = false, - item = - Kind3BasicRelaySetupInfo( - url = "nostr.mom", - read = true, - write = true, - relayStat = - RelayStat( - errorCounter = 23, - receivedBytes = 10000, - sentBytes = 10000000, - spamCounter = 10, - ), - feedTypes = activeTypesGlobalChats, - paidRelay = true, - ), - onDelete = {}, - onToggleDownload = {}, - onToggleUpload = {}, - onToggleFollows = {}, - onTogglePrivateDMs = {}, - onTogglePublicChats = {}, - onToggleGlobal = {}, - onToggleSearch = {}, - onClick = {}, - ) -} - -@Composable -fun LoadRelayInfo( - item: Kind3BasicRelaySetupInfo, - onToggleDownload: (Kind3BasicRelaySetupInfo) -> Unit, - onToggleUpload: (Kind3BasicRelaySetupInfo) -> Unit, - onToggleFollows: (Kind3BasicRelaySetupInfo) -> Unit, - onTogglePrivateDMs: (Kind3BasicRelaySetupInfo) -> Unit, - onTogglePublicChats: (Kind3BasicRelaySetupInfo) -> Unit, - onToggleGlobal: (Kind3BasicRelaySetupInfo) -> Unit, - onToggleSearch: (Kind3BasicRelaySetupInfo) -> Unit, - onDelete: (Kind3BasicRelaySetupInfo) -> Unit, - accountViewModel: AccountViewModel, - nav: INav, -) { - var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) } - val context = LocalContext.current - - relayInfo?.let { - RelayInformationDialog( - onClose = { relayInfo = null }, - relayInfo = it.relayInfo, - relayBriefInfo = it.relayBriefInfo, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - ClickableRelayItem( - item = item, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, - loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, - onToggleDownload = onToggleDownload, - onToggleUpload = onToggleUpload, - onToggleFollows = onToggleFollows, - onTogglePrivateDMs = onTogglePrivateDMs, - onTogglePublicChats = onTogglePublicChats, - onToggleGlobal = onToggleGlobal, - onToggleSearch = onToggleSearch, - onDelete = onDelete, - onClick = { - accountViewModel.retrieveRelayDocument( - item.url, - onInfo = { - relayInfo = RelayInfoDialog(RelayBriefInfoCache.RelayBriefInfo(item.url), it) - }, - onError = { url, errorCode, exceptionMessage -> - val msg = - when (errorCode) { - Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - - Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - - Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - - Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - } - - accountViewModel.toastManager.toast( - stringRes(context, R.string.unable_to_download_relay_document), - msg, - ) - }, - ) - }, - ) -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun ClickableRelayItem( - item: Kind3BasicRelaySetupInfo, - loadProfilePicture: Boolean, - loadRobohash: Boolean, - onToggleDownload: (Kind3BasicRelaySetupInfo) -> Unit, - onToggleUpload: (Kind3BasicRelaySetupInfo) -> Unit, - onToggleFollows: (Kind3BasicRelaySetupInfo) -> Unit, - onTogglePrivateDMs: (Kind3BasicRelaySetupInfo) -> Unit, - onTogglePublicChats: (Kind3BasicRelaySetupInfo) -> Unit, - onToggleGlobal: (Kind3BasicRelaySetupInfo) -> Unit, - onToggleSearch: (Kind3BasicRelaySetupInfo) -> Unit, - onDelete: (Kind3BasicRelaySetupInfo) -> Unit, - onClick: () -> Unit, -) { - val clipboardManager = LocalClipboardManager.current - Column( - Modifier - .fillMaxWidth() - .combinedClickable( - onClick = onClick, - onLongClick = { - clipboardManager.setText(AnnotatedString(item.briefInfo.url)) - }, - ), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = 5.dp), - ) { - val iconUrlFromRelayInfoDoc = - remember(item) { - Nip11CachedRetriever.getFromCache(item.url)?.icon - } - - RenderRelayIcon( - item.briefInfo.displayUrl, - iconUrlFromRelayInfoDoc ?: item.briefInfo.favIcon, - loadProfilePicture, - loadRobohash, - item.relayStat.pingInMs, - MaterialTheme.colorScheme.largeRelayIconModifier, - ) - - Spacer(modifier = HalfHorzPadding) - - Column(Modifier.weight(1f)) { - FirstLine(item, onClick, onDelete, ReactionRowHeightChatMaxWidth) - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = ReactionRowHeightChatMaxWidth, - ) { - ActiveToggles( - item = item, - onToggleFollows = onToggleFollows, - onTogglePrivateDMs = onTogglePrivateDMs, - onTogglePublicChats = onTogglePublicChats, - onToggleGlobal = onToggleGlobal, - ) - } - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = ReactionRowHeightChatMaxWidth, - ) { - StatusRow( - item = item, - onToggleDownload = onToggleDownload, - onToggleUpload = onToggleUpload, - modifier = HalfStartPadding.weight(1f), - ) - } - } - } - - HorizontalDivider(thickness = DividerThickness) - } -} - -@Composable -@OptIn(ExperimentalFoundationApi::class) -private fun StatusRow( - item: Kind3BasicRelaySetupInfo, - onToggleDownload: (Kind3BasicRelaySetupInfo) -> Unit, - onToggleUpload: (Kind3BasicRelaySetupInfo) -> Unit, - modifier: Modifier, -) { - val scope = rememberCoroutineScope() - val context = LocalContext.current - - Icon( - imageVector = Icons.Default.Download, - contentDescription = stringRes(R.string.read_from_relay), - modifier = - Modifier - .size(15.dp) - .combinedClickable( - onClick = { onToggleDownload(item) }, - onLongClick = { - scope.launch { - Toast - .makeText( - context, - stringRes(context, R.string.read_from_relay), - Toast.LENGTH_SHORT, - ).show() - } - }, - ), - tint = - if (item.read) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f, - ) - }, - ) - - Text( - text = countToHumanReadableBytes(item.relayStat.receivedBytes), - maxLines = 1, - fontSize = 12.sp, - modifier = modifier, - color = MaterialTheme.colorScheme.placeholderText, - ) - - Icon( - imageVector = Icons.Default.Upload, - stringRes(R.string.write_to_relay), - modifier = - Modifier - .size(15.dp) - .combinedClickable( - onClick = { onToggleUpload(item) }, - onLongClick = { - scope.launch { - Toast - .makeText( - context, - stringRes(context, R.string.write_to_relay), - Toast.LENGTH_SHORT, - ).show() - } - }, - ), - tint = - if (item.write) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f, - ) - }, - ) - - Text( - text = countToHumanReadableBytes(item.relayStat.sentBytes), - maxLines = 1, - fontSize = 12.sp, - modifier = modifier, - color = MaterialTheme.colorScheme.placeholderText, - ) - - Icon( - imageVector = Icons.Default.SyncProblem, - stringRes(R.string.errors), - modifier = - Modifier - .size(15.dp) - .combinedClickable( - onClick = {}, - onLongClick = { - scope.launch { - Toast - .makeText( - context, - stringRes(context, R.string.errors), - Toast.LENGTH_SHORT, - ).show() - } - }, - ), - tint = - if (item.relayStat.errorCounter > 0) { - MaterialTheme.colorScheme.warningColor - } else { - MaterialTheme.colorScheme.allGoodColor - }, - ) - - Text( - text = countToHumanReadable(item.relayStat.errorCounter, "errors"), - maxLines = 1, - fontSize = 12.sp, - modifier = modifier, - color = MaterialTheme.colorScheme.placeholderText, - ) - - Icon( - imageVector = Icons.Default.DeleteSweep, - stringRes(R.string.spam), - modifier = - Modifier - .size(15.dp) - .combinedClickable( - onClick = {}, - onLongClick = { - scope.launch { - Toast - .makeText( - context, - stringRes(context, R.string.spam), - Toast.LENGTH_SHORT, - ).show() - } - }, - ), - tint = - if (item.relayStat.spamCounter > 0) { - MaterialTheme.colorScheme.warningColor - } else { - MaterialTheme.colorScheme.allGoodColor - }, - ) - - Text( - text = countToHumanReadable(item.relayStat.spamCounter, "spam"), - maxLines = 1, - fontSize = 12.sp, - modifier = modifier, - color = MaterialTheme.colorScheme.placeholderText, - ) -} - -@Composable -@OptIn(ExperimentalFoundationApi::class) -private fun ActiveToggles( - item: Kind3BasicRelaySetupInfo, - onToggleFollows: (Kind3BasicRelaySetupInfo) -> Unit, - onTogglePrivateDMs: (Kind3BasicRelaySetupInfo) -> Unit, - onTogglePublicChats: (Kind3BasicRelaySetupInfo) -> Unit, - onToggleGlobal: (Kind3BasicRelaySetupInfo) -> Unit, -) { - val scope = rememberCoroutineScope() - val context = LocalContext.current - - Text( - text = stringRes(id = R.string.active_for), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.padding(start = 2.dp, end = 5.dp), - fontSize = 14.sp, - ) - - IconButton( - modifier = Size30Modifier, - onClick = { onToggleFollows(item) }, - ) { - Icon( - painterResource(R.drawable.ic_home), - stringRes(R.string.home_feed), - modifier = - Modifier - .padding(horizontal = 5.dp) - .size(15.dp) - .combinedClickable( - onClick = { onToggleFollows(item) }, - onLongClick = { - scope.launch { - Toast - .makeText( - context, - stringRes(context, R.string.home_feed), - Toast.LENGTH_SHORT, - ).show() - } - }, - ), - tint = - if (item.feedTypes.contains(FeedType.FOLLOWS)) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f, - ) - }, - ) - } - IconButton( - modifier = Size30Modifier, - onClick = { onTogglePrivateDMs(item) }, - ) { - Icon( - painterResource(R.drawable.ic_dm), - stringRes(R.string.private_message_feed), - modifier = - Modifier - .padding(horizontal = 5.dp) - .size(15.dp) - .combinedClickable( - onClick = { onTogglePrivateDMs(item) }, - onLongClick = { - scope.launch { - Toast - .makeText( - context, - stringRes(context, R.string.private_message_feed), - Toast.LENGTH_SHORT, - ).show() - } - }, - ), - tint = - if (item.feedTypes.contains(FeedType.PRIVATE_DMS)) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f, - ) - }, - ) - } - IconButton( - modifier = Size30Modifier, - onClick = { onTogglePublicChats(item) }, - ) { - Icon( - imageVector = Icons.Default.Groups, - contentDescription = stringRes(R.string.public_chat_feed), - modifier = - Modifier - .padding(horizontal = 5.dp) - .size(15.dp) - .combinedClickable( - onClick = { onTogglePublicChats(item) }, - onLongClick = { - scope.launch { - Toast - .makeText( - context, - stringRes(context, R.string.public_chat_feed), - Toast.LENGTH_SHORT, - ).show() - } - }, - ), - tint = - if (item.feedTypes.contains(FeedType.PUBLIC_CHATS)) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f, - ) - }, - ) - } - IconButton( - modifier = Size30Modifier, - onClick = { onToggleGlobal(item) }, - ) { - Icon( - imageVector = Icons.Default.Public, - stringRes(R.string.global_feed), - modifier = - Modifier - .padding(horizontal = 5.dp) - .size(15.dp) - .combinedClickable( - onClick = { onToggleGlobal(item) }, - onLongClick = { - scope.launch { - Toast - .makeText( - context, - stringRes(context, R.string.global_feed), - Toast.LENGTH_SHORT, - ).show() - } - }, - ), - tint = - if (item.feedTypes.contains(FeedType.GLOBAL)) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f, - ) - }, - ) - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun FirstLine( - item: Kind3BasicRelaySetupInfo, - onClick: () -> Unit, - onDelete: (Kind3BasicRelaySetupInfo) -> Unit, - modifier: Modifier, -) { - val clipboardManager = LocalClipboardManager.current - Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { - Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { - Text( - text = item.briefInfo.displayUrl, - modifier = - Modifier.combinedClickable( - onClick = onClick, - onLongClick = { - clipboardManager.setText(AnnotatedString(item.briefInfo.url)) - }, - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - - if (item.paidRelay) { - Icon( - imageVector = Icons.Default.Paid, - null, - modifier = Modifier.padding(start = 5.dp, top = 1.dp).size(14.dp), - tint = MaterialTheme.colorScheme.allGoodColor, - ) - } - } - - IconButton( - modifier = Modifier.size(30.dp), - onClick = { onDelete(item) }, - ) { - Icon( - imageVector = Icons.Default.Cancel, - contentDescription = stringRes(id = R.string.remove), - modifier = Modifier.padding(start = 10.dp).size(15.dp), - tint = WarningColor, - ) - } - } -} - -@Composable -fun Kind3RelayEditBox( - relayToAdd: String, - onNewRelay: (Kind3BasicRelaySetupInfo) -> Unit, -) { - var url by remember { mutableStateOf(relayToAdd) } - var read by remember { mutableStateOf(true) } - var write by remember { mutableStateOf(true) } - - Row(verticalAlignment = Alignment.CenterVertically) { - OutlinedTextField( - label = { Text(text = stringRes(R.string.add_a_relay)) }, - modifier = Modifier.weight(1f), - value = url, - onValueChange = { url = it }, - placeholder = { - Text( - text = "server.com", - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - ) - }, - singleLine = true, - ) - - IconButton(onClick = { read = !read }) { - Icon( - imageVector = Icons.Default.Download, - contentDescription = stringRes(id = R.string.read_from_relay), - modifier = Modifier.size(Size35dp).padding(horizontal = 5.dp), - tint = - if (read) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.placeholderText - }, - ) - } - - IconButton(onClick = { write = !write }) { - Icon( - imageVector = Icons.Default.Upload, - contentDescription = stringRes(id = R.string.write_to_relay), - modifier = Modifier.size(Size35dp).padding(horizontal = 5.dp), - tint = - if (write) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.placeholderText - }, - ) - } - - Button( - onClick = { - if (url.isNotBlank() && url != "/") { - val normalized = RelayUrlFormatter.normalize(url) - onNewRelay( - Kind3BasicRelaySetupInfo( - url = normalized, - read = read, - write = write, - feedTypes = activeTypesGlobalChats, - relayStat = RelayStats.get(normalized), - ), - ) - url = "" - write = true - read = true - } - }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = - if (url.isNotBlank()) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.placeholderText - }, - ), - ) { - Text(text = stringRes(id = R.string.add), color = Color.White) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3RelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3RelayListViewModel.kt deleted file mode 100644 index 6b561f488e..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3RelayListViewModel.kt +++ /dev/null @@ -1,294 +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.ui.screen.loggedIn.relays.kind3 - -import androidx.compose.runtime.Stable -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.Nip11CachedRetriever -import com.vitorpamplona.amethyst.service.replace -import com.vitorpamplona.amethyst.service.togglePresenceInSet -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.recommendations.Kind3RelayProposalSetupInfo -import com.vitorpamplona.ammolite.relays.Constants -import com.vitorpamplona.ammolite.relays.Constants.activeTypesGlobalChats -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.RelaySetupInfo -import com.vitorpamplona.ammolite.relays.RelayStats -import com.vitorpamplona.quartz.nip65RelayList.RelayListRecommendationProcessor -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter -import kotlinx.collections.immutable.toImmutableSet -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlin.collections.plus - -@Stable -class Kind3RelayListViewModel : ViewModel() { - private lateinit var account: Account - - private val _relays = MutableStateFlow>(emptyList()) - val relays = _relays.asStateFlow() - - private val _proposedRelays = MutableStateFlow>(emptyList()) - val proposedRelays = _proposedRelays.asStateFlow() - - var hasModified = false - - fun load(account: Account) { - this.account = account - clear() - loadRelayDocuments() - } - - fun create() { - if (hasModified) { - viewModelScope.launch(Dispatchers.IO) { - account.saveKind3RelayList( - relays.value.map { - RelaySetupInfo( - it.url, - it.read, - it.write, - it.feedTypes, - ) - }, - ) - clear() - } - } - } - - fun loadRelayDocuments() { - viewModelScope.launch(Dispatchers.IO) { - _relays.value.forEach { item -> - Nip11CachedRetriever.loadRelayInfo( - dirtyUrl = item.url, - okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForDirty(item.url)) }, - onInfo = { - togglePaidRelay(item, it.limitation?.payment_required ?: false) - }, - onError = { url, errorCode, exceptionMessage -> }, - ) - } - } - } - - fun clear() { - hasModified = false - _relays.update { - var relayFile = account.userProfile().latestContactList?.relays() - - if (relayFile != null) { - relayFile - .map { - val localInfoFeedTypes = - account.settings.localRelays - .filter { localRelay -> localRelay.url == it.key } - .firstOrNull() - ?.feedTypes - ?: Constants.defaultRelays - .filter { defaultRelay -> defaultRelay.url == it.key } - .firstOrNull() - ?.feedTypes - ?: activeTypesGlobalChats.toImmutableSet() - - Kind3BasicRelaySetupInfo( - url = RelayUrlFormatter.normalize(it.key), - read = it.value.read, - write = it.value.write, - feedTypes = localInfoFeedTypes, - relayStat = RelayStats.get(it.key), - ) - }.distinctBy { it.url } - .sortedBy { it.relayStat.receivedBytes } - .reversed() - } else { - account.settings.localRelays - .map { - Kind3BasicRelaySetupInfo( - url = RelayUrlFormatter.normalize(it.url), - read = it.read, - write = it.write, - feedTypes = it.feedTypes, - relayStat = RelayStats.get(it.url), - ) - }.distinctBy { it.url } - .sortedBy { it.relayStat.receivedBytes } - .reversed() - } - } - - refreshProposals() - } - - private fun refreshProposals() { - _proposedRelays.update { - val proposed = - RelayListRecommendationProcessor - .reliableRelaySetFor( - account.liveKind3Follows.value.authors.mapNotNull { - account.getNIP65RelayList(it) - }, - relayUrlsToIgnore = - _relays.value.mapNotNullTo(HashSet()) { - if (it.read && FeedType.FOLLOWS in it.feedTypes) { - it.url - } else { - null - } - }, - hasOnionConnection = false, - ).sortedByDescending { it.users.size } - - proposed.mapNotNull { - if (it.requiredToNotMissEvents) { - Kind3RelayProposalSetupInfo( - url = RelayUrlFormatter.normalize(it.url), - read = true, - write = false, - feedTypes = setOf(FeedType.FOLLOWS), - relayStat = RelayStats.get(it.url), - users = it.users.sorted(), - ) - } else { - null - } - } - } - } - - fun addAll(defaultRelays: Array) { - hasModified = true - - _relays.update { - defaultRelays - .map { - Kind3BasicRelaySetupInfo( - url = RelayUrlFormatter.normalize(it.url), - read = it.read, - write = it.write, - feedTypes = it.feedTypes, - relayStat = RelayStats.get(it.url), - ) - }.distinctBy { it.url } - .sortedBy { it.relayStat.receivedBytes } - .reversed() - } - } - - fun addRelay(relay: Kind3BasicRelaySetupInfo) { - if (relays.value.any { it.url == relay.url }) return - - _relays.update { it.plus(relay) } - - refreshProposals() - - hasModified = true - } - - fun addRelay(relay: Kind3RelayProposalSetupInfo) { - if (relays.value.any { it.url == relay.url }) return - - _relays.update { - it.plus( - Kind3BasicRelaySetupInfo( - relay.url, - relay.read, - relay.write, - relay.feedTypes, - relay.relayStat, - relay.paidRelay, - ), - ) - } - - refreshProposals() - - hasModified = true - } - - fun deleteRelay(relay: Kind3BasicRelaySetupInfo) { - _relays.update { it.minus(relay) } - - refreshProposals() - - hasModified = true - } - - fun deleteAll() { - _relays.update { relays -> emptyList() } - - refreshProposals() - - hasModified = true - } - - fun toggleDownload(relay: Kind3BasicRelaySetupInfo) { - _relays.update { it.replace(relay, relay.copy(read = !relay.read)) } - hasModified = true - } - - fun toggleUpload(relay: Kind3BasicRelaySetupInfo) { - _relays.update { it.replace(relay, relay.copy(write = !relay.write)) } - hasModified = true - } - - fun toggleFollows(relay: Kind3BasicRelaySetupInfo) { - val newTypes = relay.feedTypes.togglePresenceInSet(FeedType.FOLLOWS) - _relays.update { it.replace(relay, relay.copy(feedTypes = newTypes)) } - hasModified = true - } - - fun toggleMessages(relay: Kind3BasicRelaySetupInfo) { - val newTypes = relay.feedTypes.togglePresenceInSet(FeedType.PRIVATE_DMS) - _relays.update { it.replace(relay, relay.copy(feedTypes = newTypes)) } - hasModified = true - } - - fun togglePublicChats(relay: Kind3BasicRelaySetupInfo) { - val newTypes = relay.feedTypes.togglePresenceInSet(FeedType.PUBLIC_CHATS) - _relays.update { it.replace(relay, relay.copy(feedTypes = newTypes)) } - hasModified = true - } - - fun toggleGlobal(relay: Kind3BasicRelaySetupInfo) { - val newTypes = relay.feedTypes.togglePresenceInSet(FeedType.GLOBAL) - _relays.update { it.replace(relay, relay.copy(feedTypes = newTypes)) } - hasModified = true - } - - fun toggleSearch(relay: Kind3BasicRelaySetupInfo) { - val newTypes = relay.feedTypes.togglePresenceInSet(FeedType.SEARCH) - _relays.update { it.replace(relay, relay.copy(feedTypes = newTypes)) } - hasModified = true - } - - fun togglePaidRelay( - relay: Kind3BasicRelaySetupInfo, - paid: Boolean, - ) { - _relays.update { it.replace(relay, relay.copy(paidRelay = paid)) } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListView.kt index 362cd20b3d..01a9fe26d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListView.kt @@ -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 @@ -29,8 +29,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog @@ -64,7 +64,7 @@ fun LazyListScope.renderLocalItems( accountViewModel: AccountViewModel, nav: INav, ) { - itemsIndexed(feedState, key = { _, item -> "Local" + item.url }) { index, item -> + itemsIndexed(feedState, key = { _, item -> "Local" + item.relay }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListViewModel.kt index e010dff3f2..496190e8eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListViewModel.kt @@ -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,11 +21,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.local import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class LocalRelayListViewModel : BasicRelaySetupInfoModel() { - override fun getRelayList(): List = account.settings.localRelayServers.toList() + override fun getRelayList(): List = + account.localRelayList.flow.value + .toList() - override fun saveRelayList(urlList: List) { - account.settings.updateLocalRelayServers(urlList.toSet()) + override suspend fun saveRelayList(urlList: List) { + account.localRelayList.saveRelayList(urlList) {} } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt index c69f3b514f..e447b5a0ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt @@ -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 @@ -29,8 +29,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog @@ -64,7 +64,7 @@ fun LazyListScope.renderPrivateOutboxItems( accountViewModel: AccountViewModel, nav: INav, ) { - itemsIndexed(feedState, key = { _, item -> "Outbox" + item.url }) { index, item -> + itemsIndexed(feedState, key = { _, item -> "Outbox" + item.relay }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt index 69dec48da6..d90292c232 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt @@ -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,11 +21,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip37 import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class PrivateOutboxRelayListViewModel : BasicRelaySetupInfoModel() { - override fun getRelayList(): List? = account.getPrivateOutboxRelayList()?.relays() + override fun getRelayList(): List? = + account.privateStorageRelayList.flow.value + .toList() - override fun saveRelayList(urlList: List) { + override suspend fun saveRelayList(urlList: List) { account.savePrivateOutboxRelayList(urlList) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt index 2832049362..12be77dfe3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt @@ -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 @@ -29,8 +29,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog @@ -67,7 +67,7 @@ fun LazyListScope.renderNip65HomeItems( accountViewModel: AccountViewModel, nav: INav, ) { - itemsIndexed(feedState, key = { _, item -> "Nip65Home" + item.url }) { index, item -> + itemsIndexed(feedState, key = { _, item -> "Nip65Home" + item.relay }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteHomeRelay(item) }, @@ -88,7 +88,7 @@ fun LazyListScope.renderNip65NotifItems( accountViewModel: AccountViewModel, nav: INav, ) { - itemsIndexed(feedState, key = { _, item -> "Nip65Notif" + item.url }) { index, item -> + itemsIndexed(feedState, key = { _, item -> "Nip65Notif" + item.relay }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteNotifRelay(item) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt index 628b9a73ad..c2d82c8c8f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt @@ -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,9 +27,11 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.Nip11CachedRetriever import com.vitorpamplona.amethyst.service.replace +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder -import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -38,6 +40,7 @@ import kotlinx.coroutines.launch @Stable class Nip65RelayListViewModel : ViewModel() { + private lateinit var accountViewModel: AccountViewModel private lateinit var account: Account private val _homeRelays = MutableStateFlow>(emptyList()) @@ -48,17 +51,21 @@ class Nip65RelayListViewModel : ViewModel() { var hasModified = false - fun load(account: Account) { - this.account = account + fun init(accountViewModel: AccountViewModel) { + this.accountViewModel = accountViewModel + this.account = accountViewModel.account + } + + fun load() { clear() loadRelayDocuments() } fun create() { if (hasModified) { - viewModelScope.launch(Dispatchers.IO) { - val writes = _homeRelays.value.map { it.url }.toSet() - val reads = _notificationRelays.value.map { it.url }.toSet() + accountViewModel.runIOCatching { + val writes = _homeRelays.value.map { it.relay }.toSet() + val reads = _notificationRelays.value.map { it.relay }.toSet() val urls = writes.union(reads) @@ -66,13 +73,14 @@ class Nip65RelayListViewModel : ViewModel() { urls.map { val type = if (writes.contains(it) && reads.contains(it)) { - AdvertisedRelayListEvent.AdvertisedRelayType.BOTH + AdvertisedRelayType.BOTH } else if (writes.contains(it)) { - AdvertisedRelayListEvent.AdvertisedRelayType.WRITE + AdvertisedRelayType.WRITE } else { - AdvertisedRelayListEvent.AdvertisedRelayType.READ + AdvertisedRelayType.READ } - AdvertisedRelayListEvent.AdvertisedRelayInfo(it, type) + + AdvertisedRelayInfo(it, type) }, ) clear() @@ -84,8 +92,8 @@ class Nip65RelayListViewModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { _homeRelays.value.forEach { item -> Nip11CachedRetriever.loadRelayInfo( - dirtyUrl = item.url, - okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForDirty(item.url)) }, + relay = item.relay, + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.torRelayState.shouldUseTorForClean(item.relay)) }, onInfo = { toggleHomePaidRelay(item, it.limitation?.payment_required ?: false) }, @@ -95,8 +103,8 @@ class Nip65RelayListViewModel : ViewModel() { _notificationRelays.value.forEach { item -> Nip11CachedRetriever.loadRelayInfo( - dirtyUrl = item.url, - okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForDirty(item.url)) }, + relay = item.relay, + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.torRelayState.shouldUseTorForClean(item.relay)) }, onInfo = { toggleNotifPaidRelay(item, it.limitation?.payment_required ?: false) }, @@ -109,28 +117,28 @@ class Nip65RelayListViewModel : ViewModel() { fun clear() { hasModified = false _homeRelays.update { - val relayList = account.getNIP65RelayList()?.writeRelays() ?: emptyList() + val relayList = account.nip65RelayList.getNIP65RelayList()?.writeRelaysNorm() ?: emptyList() relayList .map { relaySetupInfoBuilder(it) } - .distinctBy { it.url } + .distinctBy { it.relay } .sortedBy { it.relayStat.receivedBytes } .reversed() } _notificationRelays.update { - val relayList = account.getNIP65RelayList()?.readRelays() ?: emptyList() + val relayList = account.nip65RelayList.getNIP65RelayList()?.readRelaysNorm() ?: emptyList() relayList .map { relaySetupInfoBuilder(it) } - .distinctBy { it.url } + .distinctBy { it.relay } .sortedBy { it.relayStat.receivedBytes } .reversed() } } fun addHomeRelay(relay: BasicRelaySetupInfo) { - if (_homeRelays.value.any { it.url == relay.url }) return + if (_homeRelays.value.any { it.relay == relay.relay }) return _homeRelays.update { it.plus(relay) } hasModified = true @@ -154,7 +162,7 @@ class Nip65RelayListViewModel : ViewModel() { } fun addNotifRelay(relay: BasicRelaySetupInfo) { - if (_notificationRelays.value.any { it.url == relay.url }) return + if (_notificationRelays.value.any { it.relay == relay.relay }) return _notificationRelays.update { it.plus(relay) } hasModified = true diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListView.kt new file mode 100644 index 0000000000..1580a41150 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListView.kt @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.proxy + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer + +@Composable +fun ProxyRelayList( + postViewModel: ProxyRelayListViewModel, + accountViewModel: AccountViewModel, + onClose: () -> Unit, + nav: INav, +) { + val feedState by postViewModel.relays.collectAsStateWithLifecycle() + + val newNav = rememberExtendedNav(nav, onClose) + + Row(verticalAlignment = Alignment.CenterVertically) { + LazyColumn( + contentPadding = FeedPadding, + ) { + renderProxyItems(feedState, postViewModel, accountViewModel, newNav) + } + } +} + +fun LazyListScope.renderProxyItems( + feedState: List, + postViewModel: ProxyRelayListViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + itemsIndexed(feedState, key = { _, item -> "Proxy" + item.relay }) { index, item -> + BasicRelaySetupInfoDialog( + item, + onDelete = { postViewModel.deleteRelay(item) }, + accountViewModel = accountViewModel, + nav, + ) + } + + item { + Spacer(modifier = StdVertSpacer) + RelayUrlEditField { postViewModel.addRelay(relaySetupInfoBuilder(it)) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt new file mode 100644 index 0000000000..5985a0c14d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt @@ -0,0 +1,34 @@ +/** + * 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.ui.screen.loggedIn.relays.proxy + +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class ProxyRelayListViewModel : BasicRelaySetupInfoModel() { + override fun getRelayList(): List? = + account.proxyRelayList.flow.value + .toList() + + override suspend fun saveRelayList(urlList: List) { + account.saveProxyRelayList(urlList) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/recommendations/Kind3RelaySetupInfoProposalDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/recommendations/Kind3RelaySetupInfoProposalDialog.kt deleted file mode 100644 index 295aebfc46..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/recommendations/Kind3RelaySetupInfoProposalDialog.kt +++ /dev/null @@ -1,118 +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.ui.screen.loggedIn.relays.recommendations - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.FeatureSetType -import com.vitorpamplona.amethyst.service.Nip11Retriever -import com.vitorpamplona.amethyst.ui.actions.RelayInfoDialog -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationDialog -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache - -@Composable -fun Kind3RelaySetupInfoProposalDialog( - item: Kind3RelayProposalSetupInfo, - onAdd: (Kind3RelayProposalSetupInfo) -> Unit, - accountViewModel: AccountViewModel, - nav: INav, -) { - var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) } - val context = LocalContext.current - - relayInfo?.let { - RelayInformationDialog( - onClose = { relayInfo = null }, - relayInfo = it.relayInfo, - relayBriefInfo = it.relayBriefInfo, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - Kind3RelaySetupInfoProposalRow( - item = item, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, - loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, - onAdd = { - onAdd(item) - }, - accountViewModel = accountViewModel, - onClick = { - accountViewModel.retrieveRelayDocument( - item.url, - onInfo = { - relayInfo = RelayInfoDialog(RelayBriefInfoCache.RelayBriefInfo(item.url), it) - }, - onError = { url, errorCode, exceptionMessage -> - val msg = - when (errorCode) { - Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - - Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - - Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - - Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - } - - accountViewModel.toastManager.toast( - stringRes(context, R.string.unable_to_download_relay_document), - msg, - ) - }, - ) - }, - nav = nav, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/recommendations/Kind3RelaySetupInfoProposalRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/recommendations/Kind3RelaySetupInfoProposalRow.kt deleted file mode 100644 index 3ded4f7021..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/recommendations/Kind3RelaySetupInfoProposalRow.kt +++ /dev/null @@ -1,202 +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.ui.screen.loggedIn.relays.recommendations - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Paid -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.service.Nip11CachedRetriever -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.note.AddRelayButton -import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon -import com.vitorpamplona.amethyst.ui.note.UserPicture -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding -import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChatMaxWidth -import com.vitorpamplona.amethyst.ui.theme.Size25dp -import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn -import com.vitorpamplona.amethyst.ui.theme.allGoodColor -import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.quartz.nip01Core.relay.RelayStat - -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun Kind3RelaySetupInfoProposalRow( - item: Kind3RelayProposalSetupInfo, - loadProfilePicture: Boolean, - loadRobohash: Boolean, - onAdd: () -> Unit, - onClick: () -> Unit, - accountViewModel: AccountViewModel, - nav: INav, -) { - Column( - Modifier - .fillMaxWidth() - .clickable(onClick = onClick), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = 5.dp), - ) { - val iconUrlFromRelayInfoDoc = - remember(item) { - Nip11CachedRetriever.getFromCache(item.url)?.icon - } - - RenderRelayIcon( - item.briefInfo.displayUrl, - iconUrlFromRelayInfoDoc ?: item.briefInfo.favIcon, - loadProfilePicture, - loadRobohash, - item.relayStat.pingInMs, - MaterialTheme.colorScheme.largeRelayIconModifier, - ) - - Spacer(modifier = HalfHorzPadding) - - Column(Modifier.weight(1f)) { - Row(ReactionRowHeightChatMaxWidth, verticalAlignment = Alignment.CenterVertically) { - Text( - text = item.briefInfo.displayUrl, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - - if (item.paidRelay) { - Icon( - imageVector = Icons.Default.Paid, - null, - modifier = - Modifier - .padding(start = 5.dp, top = 1.dp) - .size(14.dp), - tint = MaterialTheme.colorScheme.allGoodColor, - ) - } - } - } - - UsedBy(item, accountViewModel, nav) - - Column( - Modifier - .padding(start = 10.dp), - ) { - AddRelayButton(onAdd) - } - } - - HorizontalDivider(thickness = DividerThickness) - } -} - -@Preview -@Composable -fun UsedByPreview() { - ThemeComparisonColumn { - UsedBy( - item = - Kind3RelayProposalSetupInfo( - "wss://nos.lol", - true, - true, - COMMON_FEED_TYPES, - relayStat = RelayStat(), - paidRelay = false, - users = listOf("User1", "User2", "User3", "User4"), - ), - accountViewModel = mockAccountViewModel(), - nav = EmptyNav, - ) - } -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun UsedBy( - item: Kind3RelayProposalSetupInfo, - accountViewModel: AccountViewModel, - nav: INav, -) { - FlowRow(verticalArrangement = Arrangement.Center) { - item.users.getOrNull(0)?.let { - UserPicture( - userHex = it, - size = Size25dp, - accountViewModel = accountViewModel, - nav = nav, - ) - } - item.users.getOrNull(1)?.let { - UserPicture( - userHex = it, - size = Size25dp, - accountViewModel = accountViewModel, - nav = nav, - ) - } - item.users.getOrNull(2)?.let { - UserPicture( - userHex = it, - size = Size25dp, - accountViewModel = accountViewModel, - nav = nav, - ) - } - if (item.users.size > 3) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.height(Size25dp)) { - Text( - text = stringRes(R.string.and_more, item.users.size - 3), - maxLines = 1, - ) - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListView.kt index d446b45204..139e5344c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListView.kt @@ -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 @@ -29,8 +29,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog @@ -65,7 +65,7 @@ fun LazyListScope.renderSearchItems( accountViewModel: AccountViewModel, nav: INav, ) { - itemsIndexed(feedState, key = { _, item -> "Search" + item.url }) { index, item -> + itemsIndexed(feedState, key = { _, item -> "Search" + item.relay }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt index 7c469182da..4fce997dd5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt @@ -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,11 +21,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class SearchRelayListViewModel : BasicRelaySetupInfoModel() { - override fun getRelayList(): List? = account.getSearchRelayList()?.relays() + override fun getRelayList(): List? = + account.searchRelayList.flow.value + .toList() - override fun saveRelayList(urlList: List) { + override suspend fun saveRelayList(urlList: List) { account.saveSearchRelayList(urlList) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListView.kt new file mode 100644 index 0000000000..355506f36b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListView.kt @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.trusted + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer + +@Composable +fun TrustedRelayList( + postViewModel: TrustedRelayListViewModel, + accountViewModel: AccountViewModel, + onClose: () -> Unit, + nav: INav, +) { + val feedState by postViewModel.relays.collectAsStateWithLifecycle() + + val newNav = rememberExtendedNav(nav, onClose) + + Row(verticalAlignment = Alignment.CenterVertically) { + LazyColumn( + contentPadding = FeedPadding, + ) { + renderTrustedItems(feedState, postViewModel, accountViewModel, newNav) + } + } +} + +fun LazyListScope.renderTrustedItems( + feedState: List, + postViewModel: TrustedRelayListViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + itemsIndexed(feedState, key = { _, item -> "Trusted" + item.relay }) { index, item -> + BasicRelaySetupInfoDialog( + item, + onDelete = { postViewModel.deleteRelay(item) }, + accountViewModel = accountViewModel, + nav, + ) + } + + item { + Spacer(modifier = StdVertSpacer) + RelayUrlEditField { postViewModel.addRelay(relaySetupInfoBuilder(it)) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListViewModel.kt new file mode 100644 index 0000000000..1d1f718828 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListViewModel.kt @@ -0,0 +1,34 @@ +/** + * 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.ui.screen.loggedIn.relays.trusted + +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class TrustedRelayListViewModel : BasicRelaySetupInfoModel() { + override fun getRelayList(): List? = + account.trustedRelayList.flow.value + .toList() + + override suspend fun saveRelayList(urlList: List) { + account.saveTrustedRelayList(urlList) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/report/ReportNoteDialog.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/report/ReportNoteDialog.kt index 3ae9a888ce..663b21dd72 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/report/ReportNoteDialog.kt @@ -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,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn +package com.vitorpamplona.amethyst.ui.screen.loggedIn.report import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -45,6 +45,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -60,9 +61,12 @@ import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner +import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.WarningColor +import com.vitorpamplona.amethyst.ui.theme.LightRedColor import com.vitorpamplona.quartz.nip56Reports.ReportType import kotlinx.collections.immutable.toImmutableList @@ -86,7 +90,7 @@ fun ReportNoteDialog( val reasonOptions = remember { reportTypes.map { TitleExplainer(it.second) }.toImmutableList() } var additionalReason by remember { mutableStateOf("") } - var selectedReason by remember { mutableStateOf(-1) } + var selectedReason by remember { mutableIntStateOf(-1) } Dialog( onDismissRequest = onDismiss, @@ -191,7 +195,7 @@ private fun ActionButton( ) = Button( onClick = onClick, enabled = enabled, - colors = ButtonDefaults.buttonColors(containerColor = WarningColor), + colors = ButtonDefaults.buttonColors(containerColor = LightRedColor), modifier = Modifier.fillMaxWidth(), ) { Row( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForSearchCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddInboxRelayForSearchCard.kt similarity index 70% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForSearchCard.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddInboxRelayForSearchCard.kt index feb746b935..b82d461cd0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForSearchCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddInboxRelayForSearchCard.kt @@ -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,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.elements +package com.vitorpamplona.amethyst.ui.screen.loggedIn.search import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -29,7 +29,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -38,21 +37,18 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search.AddSearchRelayListDialog import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.BigPadding import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.imageModifier -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent @Preview @Composable @@ -70,47 +66,14 @@ fun ObserveRelayListForSearchAndDisplayIfNotFound( accountViewModel: AccountViewModel, nav: INav, ) { - ObserveRelayListForSearch( - accountViewModel = accountViewModel, - ) { relayListEvent -> - if (relayListEvent == null || relayListEvent.relays().isEmpty()) { - AddInboxRelayForSearchCard( - accountViewModel = accountViewModel, - nav = nav, - ) - } - } -} + val searchRelayList by accountViewModel.account.searchRelayList.flow + .collectAsStateWithLifecycle() -@Composable -fun ObserveRelayListForSearch( - accountViewModel: AccountViewModel, - inner: @Composable (relayListEvent: SearchRelayListEvent?) -> Unit, -) { - ObserveRelayListForSearch( - pubkey = accountViewModel.account.userProfile().pubkeyHex, - accountViewModel = accountViewModel, - ) { relayListEvent -> - inner(relayListEvent) - } -} - -@Composable -fun ObserveRelayListForSearch( - pubkey: HexKey, - accountViewModel: AccountViewModel, - inner: @Composable (relayListEvent: SearchRelayListEvent?) -> Unit, -) { - LoadAddressableNote( - SearchRelayListEvent.createAddressTag(pubkey), - accountViewModel, - ) { relayList -> - if (relayList != null) { - val relayListNoteState by relayList.live().metadata.observeAsState() - val relayListEvent = relayListNoteState?.note?.event as? SearchRelayListEvent - - inner(relayListEvent) - } + if (searchRelayList.isEmpty()) { + AddInboxRelayForSearchCard( + accountViewModel = accountViewModel, + nav = nav, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/AddSearchRelayListDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddSearchRelayListDialog.kt similarity index 69% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/AddSearchRelayListDialog.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddSearchRelayListDialog.kt index daee2f64c6..651223d18a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/AddSearchRelayListDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddSearchRelayListDialog.kt @@ -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,13 +18,11 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search +package com.vitorpamplona.amethyst.ui.screen.loggedIn.search import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api @@ -32,11 +30,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @@ -45,13 +40,13 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.DefaultSearchRelayList import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search.SearchRelayList +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search.SearchRelayListViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.imageModifier @@ -64,7 +59,11 @@ fun AddSearchRelayListDialog( ) { val postViewModel: SearchRelayListViewModel = viewModel() - LaunchedEffect(Unit) { postViewModel.load(accountViewModel.account) } + postViewModel.init(accountViewModel) + + LaunchedEffect(postViewModel, accountViewModel.account) { + postViewModel.load() + } Dialog( onDismissRequest = onClose, @@ -73,39 +72,16 @@ fun AddSearchRelayListDialog( SetDialogToEdgeToEdge() Scaffold( topBar = { - TopAppBar( - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer(modifier = StdHorzSpacer) - - Text(stringRes(R.string.search_relays_title)) - - SaveButton( - onPost = { - postViewModel.create() - onClose() - }, - true, - ) - } + SavingTopBar( + titleRes = R.string.search_relays_title, + onCancel = { + postViewModel.clear() + onClose() }, - navigationIcon = { - Spacer(modifier = StdHorzSpacer) - CloseButton( - onPress = { - postViewModel.clear() - onClose() - }, - ) + onPost = { + postViewModel.create() + onClose() }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), ) }, ) { pad -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index 160659ce6c..b6241290c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -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,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.search -import android.util.Log import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -29,84 +28,123 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.focus.FocusRequester import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.ammolite.relays.BundledUpdate +import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent import com.vitorpamplona.quartz.nip10Notes.content.findHashtags import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update @Stable +@OptIn(FlowPreview::class) class SearchBarViewModel( val account: Account, -) : ViewModel() { +) : ViewModel(), + InvalidatableContent { val focusRequester = FocusRequester() var searchValue by mutableStateOf("") - private var _searchResultsUsers = MutableStateFlow>(emptyList()) - private var _searchResultsNotes = MutableStateFlow>(emptyList()) - private var _searchResultsChannels = MutableStateFlow>(emptyList()) - private var _hashtagResults = MutableStateFlow>(emptyList()) + val invalidations = MutableStateFlow(0) + val searchValueFlow = MutableStateFlow("") - val searchResultsUsers = _searchResultsUsers.asStateFlow() - val searchResultsNotes = _searchResultsNotes.asStateFlow() - val searchResultsChannels = _searchResultsChannels.asStateFlow() - val hashtagResults = _hashtagResults.asStateFlow() + val searchTerm = + searchValueFlow + .debounce(300) + .distinctUntilChanged() + .onEach(::updateDataSource) + .stateIn(viewModelScope, SharingStarted.Eagerly, searchValue) - val isSearching by derivedStateOf { searchValue.isNotBlank() } + val searchDataSourceState = SearchQueryState(MutableStateFlow(searchValue), account) + + val searchResultsUsers = + combine( + searchValueFlow.debounce(100), + invalidations.debounce(100), + ) { term, version -> + LocalCache.findUsersStartingWith(term, account) + }.flowOn(Dispatchers.Default) + .stateIn(viewModelScope, WhileSubscribed(5000), emptyList()) + + val searchResultsNotes = + combine( + searchValueFlow.debounce(100), + invalidations, + ) { term, version -> + LocalCache + .findNotesStartingWith(term, account.hiddenUsers) + .sortedWith(DefaultFeedOrder) + }.flowOn(Dispatchers.Default) + .stateIn(viewModelScope, WhileSubscribed(5000), emptyList()) + + val searchResultsPublicChatChannels = + combine( + searchValueFlow.debounce(100), + invalidations, + ) { term, version -> + LocalCache.findPublicChatChannelsStartingWith(term) + }.flowOn(Dispatchers.Default) + .stateIn(viewModelScope, WhileSubscribed(5000), emptyList()) + + val searchResultsEphemeralChannels = + combine( + searchValueFlow.debounce(100), + invalidations, + ) { term, version -> + LocalCache.findEphemeralChatChannelsStartingWith(term) + }.flowOn(Dispatchers.Default) + .stateIn(viewModelScope, WhileSubscribed(5000), emptyList()) + + val searchResultsLiveActivityChannels = + combine( + searchValueFlow.debounce(100), + invalidations, + ) { term, version -> + LocalCache.findLiveActivityChannelsStartingWith(term) + }.flowOn(Dispatchers.Default) + .stateIn(viewModelScope, WhileSubscribed(5000), emptyList()) + + val hashtagResults = + combine( + searchValueFlow.debounce(100), + invalidations, + ) { term, version -> + findHashtags(term) + }.flowOn(Dispatchers.Default) + .stateIn(viewModelScope, WhileSubscribed(5000), emptyList()) + + override val isRefreshing = derivedStateOf { searchValue.isNotBlank() } + + override fun invalidateData(ignoreIfDoing: Boolean) { + // force new query + invalidations.update { it + 1 } + } fun updateSearchValue(newValue: String) { searchValue = newValue + searchValueFlow.tryEmit(newValue) } - private suspend fun runSearch() { - if (searchValue.isBlank()) { - _hashtagResults.value = emptyList() - _searchResultsUsers.value = emptyList() - _searchResultsChannels.value = emptyList() - _searchResultsNotes.value = emptyList() - return + fun clear() = updateSearchValue("") + + fun updateDataSource(searchTerm: String) { + if (searchTerm.isBlank()) { + searchDataSourceState.searchQuery.tryEmit("") + } else { + searchDataSourceState.searchQuery.tryEmit(searchTerm) } - - _hashtagResults.emit(findHashtags(searchValue)) - _searchResultsUsers.emit( - LocalCache.findUsersStartingWith(searchValue, account), - ) - _searchResultsNotes.emit( - LocalCache - .findNotesStartingWith(searchValue, account) - .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) - .reversed(), - ) - _searchResultsChannels.emit(LocalCache.findChannelsStartingWith(searchValue)) - } - - fun clear() { - searchValue = "" - _searchResultsUsers.value = emptyList() - _searchResultsChannels.value = emptyList() - _searchResultsNotes.value = emptyList() - _searchResultsChannels.value = emptyList() - } - - private val bundler = BundledUpdate(250, Dispatchers.IO) - - fun invalidateData() { - bundler.invalidate { - // adds the time to perform the refresh into this delay - // holding off new updates in case of heavy refresh routines. - runSearch() - } - } - - override fun onCleared() { - bundler.cancel() - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") - super.onCleared() } fun isSearchingFun() = searchValue.isNotBlank() @@ -114,6 +152,7 @@ class SearchBarViewModel( class Factory( val account: Account, ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): SearchBarViewModel = SearchBarViewModel(account) as SearchBarViewModel + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = SearchBarViewModel(account) as T } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index 55a13d3dce..adc9244d6a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -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 @@ -43,38 +43,33 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource -import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.service.relayClient.searchCommand.TextSearchDataSourceSubscription +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.ClearTextIcon import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.SearchIcon import com.vitorpamplona.amethyst.ui.note.UserCompose -import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForSearchAndDisplayIfNotFound import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelName import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -84,14 +79,7 @@ import com.vitorpamplona.amethyst.ui.theme.StdTopPadding import com.vitorpamplona.amethyst.ui.theme.placeholderText import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.coroutines.channels.Channel as CoroutineChannel @Composable fun SearchScreen( @@ -116,27 +104,7 @@ fun SearchScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val lifeCycleOwner = LocalLifecycleOwner.current - - WatchAccountForSearchScreen(accountViewModel) - - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Search Start") - NostrSearchEventOrUserDataSource.start() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Search Stop") - NostrSearchEventOrUserDataSource.clear() - NostrSearchEventOrUserDataSource.stop() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } + WatchLifecycleAndUpdateModel(searchBarViewModel) val listState = rememberLazyListState() @@ -147,7 +115,7 @@ fun SearchScreen( DisappearingScaffold( isInvertedLayout = false, topBar = { - SearchBar(searchBarViewModel, listState, nav) + SearchBar(searchBarViewModel, listState, accountViewModel, nav) }, bottomBar = { AppBottomBar(Route.Search, accountViewModel) { route -> @@ -165,30 +133,27 @@ fun SearchScreen( } } -@Composable -fun WatchAccountForSearchScreen(accountViewModel: AccountViewModel) { - LaunchedEffect(accountViewModel) { - launch(Dispatchers.IO) { NostrSearchEventOrUserDataSource.start() } - } -} - @OptIn(FlowPreview::class) @Composable private fun SearchBar( searchBarViewModel: SearchBarViewModel, listState: LazyListState, + accountViewModel: AccountViewModel, nav: INav, ) { - val scope = rememberCoroutineScope() - - // Create a channel for processing search queries. - val searchTextChanges = remember { CoroutineChannel(CoroutineChannel.CONFLATED) } + TextSearchDataSourceSubscription(searchBarViewModel, accountViewModel) LaunchedEffect(Unit) { - launch(Dispatchers.IO) { + launch(Dispatchers.Default) { LocalCache.live.newEventBundles.collect { - checkNotInMainThread() + if (searchBarViewModel.isSearchingFun()) { + searchBarViewModel.invalidateData() + } + } + } + launch(Dispatchers.Default) { + LocalCache.live.deletedEventBundles.collect { if (searchBarViewModel.isSearchingFun()) { searchBarViewModel.invalidateData() } @@ -196,31 +161,20 @@ private fun SearchBar( } } - LaunchedEffect(Unit) { - // Wait for text changes to stop for 300 ms before firing off search. - withContext(Dispatchers.IO) { - searchTextChanges - .receiveAsFlow() - .filter { it.isNotBlank() } - .distinctUntilChanged() - .debounce(300) - .collectLatest { - if (it.length >= 2) { - NostrSearchEventOrUserDataSource.search(it.trim()) - } + AnimateOnNewSearch(searchBarViewModel, listState) - searchBarViewModel.invalidateData() + SearchTextField(searchBarViewModel, Modifier.statusBarsPadding()) +} - // makes sure to show the top of the search - launch(Dispatchers.Main) { listState.animateScrollToItem(0) } - } - } - } +@Composable +fun AnimateOnNewSearch( + searchBarViewModel: SearchBarViewModel, + listState: LazyListState, +) { + val searchTerm by searchBarViewModel.searchTerm.collectAsStateWithLifecycle() - DisposableEffect(Unit) { onDispose { NostrSearchEventOrUserDataSource.clear() } } - - SearchTextField(searchBarViewModel, modifier = Modifier.statusBarsPadding()) { - scope.launch(Dispatchers.IO) { searchTextChanges.trySend(it) } + LaunchedEffect(searchTerm) { + listState.animateScrollToItem(0) } } @@ -228,7 +182,6 @@ private fun SearchBar( private fun SearchTextField( searchBarViewModel: SearchBarViewModel, modifier: Modifier, - onTextChanges: (String) -> Unit, ) { Row( modifier = modifier.padding(10.dp).fillMaxWidth(), @@ -239,7 +192,6 @@ private fun SearchTextField( value = searchBarViewModel.searchValue, onValueChange = { searchBarViewModel.updateSearchValue(it) - onTextChanges(it) }, shape = RoundedCornerShape(25.dp), keyboardOptions = @@ -259,11 +211,10 @@ private fun SearchTextField( ) }, trailingIcon = { - if (searchBarViewModel.isSearching) { + if (searchBarViewModel.isRefreshing.value) { IconButton( onClick = { searchBarViewModel.clear() - NostrSearchEventOrUserDataSource.clear() }, ) { ClearTextIcon() @@ -287,13 +238,15 @@ private fun DisplaySearchResults( nav: INav, accountViewModel: AccountViewModel, ) { - if (!searchBarViewModel.isSearching) { + if (!searchBarViewModel.isRefreshing.value) { return } val hashTags by searchBarViewModel.hashtagResults.collectAsStateWithLifecycle() val users by searchBarViewModel.searchResultsUsers.collectAsStateWithLifecycle() - val channels by searchBarViewModel.searchResultsChannels.collectAsStateWithLifecycle() + val publicChatChannels by searchBarViewModel.searchResultsPublicChatChannels.collectAsStateWithLifecycle() + val ephemeralChannels by searchBarViewModel.searchResultsEphemeralChannels.collectAsStateWithLifecycle() + val liveActivityChannels by searchBarViewModel.searchResultsLiveActivityChannels.collectAsStateWithLifecycle() val notes by searchBarViewModel.searchResultsNotes.collectAsStateWithLifecycle() LazyColumn( @@ -325,8 +278,8 @@ private fun DisplaySearchResults( } itemsIndexed( - channels, - key = { _, item -> "c" + item.idHex }, + publicChatChannels, + key = { _, item -> "public" + item.idHex }, ) { _, item -> ChannelName( channelIdHex = item.idHex, @@ -342,7 +295,63 @@ private fun DisplaySearchResults( hasNewMessages = false, loadProfilePicture = accountViewModel.settings.showProfilePictures.value, loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, - onClick = { nav.nav(Route.Channel(item.idHex)) }, + onClick = { nav.nav(routeFor(item)) }, + ) + + HorizontalDivider( + modifier = StdTopPadding, + thickness = DividerThickness, + ) + } + + itemsIndexed( + ephemeralChannels, + key = { _, item -> "ephem" + item.roomId.toKey() }, + ) { _, item -> + val relayInfo by loadRelayInfo(item.roomId.relayUrl, accountViewModel) + + ChannelName( + channelIdHex = item.roomId.toKey(), + channelPicture = relayInfo.icon, + channelTitle = { + Text( + item.toBestDisplayName(), + fontWeight = FontWeight.Bold, + ) + }, + channelLastTime = null, + channelLastContent = stringRes(R.string.ephemeral_relay_chat), + hasNewMessages = false, + loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, + onClick = { nav.nav(routeFor(item)) }, + ) + + HorizontalDivider( + modifier = StdTopPadding, + thickness = DividerThickness, + ) + } + + itemsIndexed( + liveActivityChannels, + key = { _, item -> "live" + item.address.toValue() }, + ) { _, item -> + ChannelName( + channelIdHex = item.address.toValue(), + channelPicture = item.profilePicture(), + channelTitle = { + Text( + item.toBestDisplayName(), + fontWeight = FontWeight.Bold, + ) + }, + channelLastTime = null, + channelLastContent = item.summary(), + hasNewMessages = false, + loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, + onClick = { nav.nav(routeFor(item)) }, ) HorizontalDivider( @@ -380,6 +389,7 @@ fun HashtagLine( start = 12.dp, end = 12.dp, top = 10.dp, + bottom = 10.dp, ), ) { Row( @@ -388,7 +398,7 @@ fun HashtagLine( modifier = Modifier.fillMaxWidth(), ) { Text( - "Search hashtag: #$tag", + stringRes(R.string.search_by_hashtag, tag), fontWeight = FontWeight.Bold, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt index efcb0a3edb..64523f48d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt @@ -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 @@ -56,11 +56,11 @@ import com.vitorpamplona.amethyst.model.parseFeatureSetType import com.vitorpamplona.amethyst.model.parseGalleryType import com.vitorpamplona.amethyst.model.parseThemeType import com.vitorpamplona.amethyst.ui.components.PushNotificationSettingsRow -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer import com.vitorpamplona.amethyst.ui.screen.mockSharedPreferencesViewModel @@ -119,12 +119,13 @@ fun getLanguageIndex( sharedPreferencesViewModel: SharedPreferencesViewModel, ): Int { val language = sharedPreferencesViewModel.sharedPrefs.language - var languageIndex = -1 - if (language != null) { - languageIndex = languageEntries.values.toTypedArray().indexOf(language) - } else { - languageIndex = languageEntries.values.toTypedArray().indexOf(Locale.current.toLanguageTag()) - } + var languageIndex: Int + languageIndex = + if (language != null) { + languageEntries.values.toTypedArray().indexOf(language) + } else { + languageEntries.values.toTypedArray().indexOf(Locale.current.toLanguageTag()) + } if (languageIndex == -1) { languageIndex = languageEntries.values.toTypedArray().indexOf(Locale.current.language) } @@ -365,7 +366,7 @@ fun SettingsRow( text = stringRes(description), style = MaterialTheme.typography.bodySmall, color = Color.Gray, - maxLines = 2, + maxLines = 3, overflow = TextOverflow.Ellipsis, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NIP47SetupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NIP47SetupScreen.kt index 4d70c3615f..6507271c18 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NIP47SetupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NIP47SetupScreen.kt @@ -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,22 +23,17 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.rememberHeightDecreaser -import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountContent import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton -import com.vitorpamplona.amethyst.ui.stringRes @Composable fun NIP47SetupScreen( @@ -47,7 +42,12 @@ fun NIP47SetupScreen( nip47: String?, ) { val postViewModel: UpdateZapAmountViewModel = viewModel() - postViewModel.load(accountViewModel.account) + postViewModel.init(accountViewModel) + + LaunchedEffect(accountViewModel, postViewModel) { + postViewModel.load() + } + NIP47SetupScreen(postViewModel, accountViewModel, nav, nip47) } @@ -61,28 +61,16 @@ fun NIP47SetupScreen( ) { Scaffold( topBar = { - TopAppBar( - scrollBehavior = rememberHeightDecreaser(), - title = { Text(stringRes(id = R.string.wallet_connect)) }, - navigationIcon = { - IconButton( - onClick = { - postViewModel.cancel() - nav.popBack() - }, - modifier = Modifier, - ) { - ArrowBackIcon() - } + SavingTopBar( + titleRes = R.string.wallet_connect, + isActive = postViewModel::hasChanged, + onCancel = { + postViewModel.cancel() + nav.popBack() }, - actions = { - SaveButton( - onPost = { - postViewModel.sendPost() - nav.popBack() - }, - isActive = postViewModel.hasChanged(), - ) + onPost = { + postViewModel.sendPost() + nav.popBack() }, ) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt index 83877c3d46..02b5a801c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt @@ -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,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight @@ -33,11 +34,11 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Checkbox import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.ScrollableTabRow +import androidx.compose.material3.Switch import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -46,7 +47,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -63,27 +63,34 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.distinctUntilChanged -import androidx.lifecycle.map import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.WarningType +import com.vitorpamplona.amethyst.model.parseWarningType +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.observeAccountIsHiddenWord import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.note.elements.AddButton -import com.vitorpamplona.amethyst.ui.screen.NostrHiddenAccountsFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.NostrSpammerAccountsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner +import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal.HiddenAccountsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal.HiddenWordsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal.SpammerAccountsFeedViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.ButtonPadding import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.HorzPadding +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size15dp import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.TabRowHeight import com.vitorpamplona.amethyst.ui.theme.placeholderText +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.launch @Composable @@ -91,19 +98,19 @@ fun SecurityFiltersScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val hiddenFeedViewModel: NostrHiddenAccountsFeedViewModel = + val hiddenFeedViewModel: HiddenAccountsFeedViewModel = viewModel( - factory = NostrHiddenAccountsFeedViewModel.Factory(accountViewModel.account), + factory = HiddenAccountsFeedViewModel.Factory(accountViewModel.account), ) - val hiddenWordsFeedViewModel: NostrHiddenWordsFeedViewModel = + val hiddenWordsFeedViewModel: HiddenWordsFeedViewModel = viewModel( - factory = NostrHiddenWordsFeedViewModel.Factory(accountViewModel.account), + factory = HiddenWordsFeedViewModel.Factory(accountViewModel.account), ) - val spammerFeedViewModel: NostrSpammerAccountsFeedViewModel = + val spammerFeedViewModel: SpammerAccountsFeedViewModel = viewModel( - factory = NostrSpammerAccountsFeedViewModel.Factory(accountViewModel.account), + factory = SpammerAccountsFeedViewModel.Factory(accountViewModel.account), ) WatchAccountAndBlockList(accountViewModel = accountViewModel) { @@ -124,9 +131,9 @@ fun SecurityFiltersScreen( @OptIn(ExperimentalFoundationApi::class) @Composable fun SecurityFiltersScreen( - hiddenFeedViewModel: NostrHiddenAccountsFeedViewModel, - hiddenWordsViewModel: NostrHiddenWordsFeedViewModel, - spammerFeedViewModel: NostrSpammerAccountsFeedViewModel, + hiddenFeedViewModel: HiddenAccountsFeedViewModel, + hiddenWordsViewModel: HiddenWordsFeedViewModel, + spammerFeedViewModel: SpammerAccountsFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { @@ -154,35 +161,17 @@ fun SecurityFiltersScreen( }, accountViewModel = accountViewModel, ) { - Column(Modifier.padding(it).fillMaxHeight()) { + Column( + Modifier + .padding(it) + .fillMaxHeight(), + ) { val pagerState = rememberPagerState { 3 } val coroutineScope = rememberCoroutineScope() - var warnAboutReports by remember { mutableStateOf(accountViewModel.account.settings.syncedSettings.security.warnAboutPostsWithReports) } - var filterSpam by remember { mutableStateOf(accountViewModel.account.settings.syncedSettings.security.filterSpamFromStrangers.value) } - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = warnAboutReports, - onCheckedChange = { - warnAboutReports = it - accountViewModel.updateOptOutOptions(warnAboutReports, filterSpam) - }, - ) + HeaderOptions(accountViewModel) - Text(stringRes(R.string.warn_when_posts_have_reports_from_your_follows)) - } - - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = filterSpam, - onCheckedChange = { - filterSpam = it - accountViewModel.updateOptOutOptions(warnAboutReports, filterSpam) - }, - ) - - Text(stringRes(R.string.filter_spam_from_strangers)) - } + HorizontalDivider() ScrollableTabRow( containerColor = MaterialTheme.colorScheme.background, @@ -219,9 +208,72 @@ fun SecurityFiltersScreen( } } +@Composable +private fun HeaderOptions(accountViewModel: AccountViewModel) { + Column( + Modifier + .padding(top = Size10dp, bottom = Size10dp, start = Size15dp, end = Size15dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = spacedBy(10.dp), + ) { + SettingsRow( + R.string.warn_when_posts_have_reports_from_your_follows_title, + R.string.warn_when_posts_have_reports_from_your_follows_explainer, + ) { + var warnAboutReports by remember { mutableStateOf(accountViewModel.account.settings.syncedSettings.security.warnAboutPostsWithReports) } + + Switch( + checked = warnAboutReports, + onCheckedChange = { + warnAboutReports = it + accountViewModel.updateWarnReports(warnAboutReports) + }, + ) + } + + SettingsRow( + R.string.filter_spam_from_strangers_title, + R.string.filter_spam_from_strangers_explainer, + ) { + var filterSpam by remember { mutableStateOf(accountViewModel.account.settings.syncedSettings.security.filterSpamFromStrangers.value) } + + Switch( + checked = filterSpam, + onCheckedChange = { + filterSpam = it + accountViewModel.updateFilterSpam(filterSpam) + }, + ) + } + + SettingsRow( + R.string.show_sensitive_content_title, + R.string.show_sensitive_content_explainer, + ) { + var sensitive by remember { mutableStateOf(accountViewModel.account.settings.syncedSettings.security.showSensitiveContent.value) } + + val selectedItens = + persistentListOf( + TitleExplainer(stringRes(WarningType.WARN.resourceId)), + TitleExplainer(stringRes(WarningType.SHOW.resourceId)), + TitleExplainer(stringRes(WarningType.HIDE.resourceId)), + ) + + TextSpinner( + label = "", + placeholder = selectedItens[parseWarningType(sensitive).screenCode].title, + options = selectedItens, + onSelect = { + accountViewModel.updateShowSensitiveContent(parseWarningType(it).prefCode) + }, + ) + } + } +} + @Composable private fun HiddenWordsFeed( - hiddenWordsViewModel: NostrHiddenWordsFeedViewModel, + hiddenWordsViewModel: HiddenWordsFeedViewModel, accountViewModel: AccountViewModel, ) { RefresheableBox(hiddenWordsViewModel, false) { @@ -283,8 +335,10 @@ fun WatchAccountAndBlockList( accountViewModel: AccountViewModel, invalidate: () -> Unit, ) { - val transientSpammers by accountViewModel.account.transientHiddenUsers.collectAsStateWithLifecycle() - val blockListState by accountViewModel.account.flowHiddenUsers.collectAsStateWithLifecycle() + val transientSpammers by accountViewModel.account.hiddenUsers.transientHiddenUsers + .collectAsStateWithLifecycle() + val blockListState by accountViewModel.account.hiddenUsers.flow + .collectAsStateWithLifecycle() LaunchedEffect(accountViewModel, transientSpammers, blockListState) { invalidate() @@ -322,11 +376,7 @@ fun MutedWordActionOptions( word: String, accountViewModel: AccountViewModel, ) { - val isMutedWord by - accountViewModel.account.liveHiddenUsers - .map { word in it.hiddenWords } - .distinctUntilChanged() - .observeAsState() + val isMutedWord by observeAccountIsHiddenWord(accountViewModel.account, word) if (isMutedWord == true) { ShowWordButton { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedState.kt index 93eca94115..b66963c5a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedState.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedView.kt index 69704776a6..a2e05c1e4a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedView.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedViewModel.kt index 57885dca6a..607d494b9f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedViewModel.kt @@ -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,36 +25,20 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.dal.FeedFilter -import com.vitorpamplona.amethyst.ui.dal.HiddenWordsFeedFilter import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists import com.vitorpamplona.ammolite.relays.BundledUpdate import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -class NostrHiddenWordsFeedViewModel( - val account: Account, -) : StringFeedViewModel( - HiddenWordsFeedFilter(account), - ) { - class Factory( - val account: Account, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrHiddenWordsFeedViewModel = NostrHiddenWordsFeedViewModel(account) as NostrHiddenWordsFeedViewModel - } -} - @Stable open class StringFeedViewModel( val dataSource: FeedFilter, @@ -113,26 +97,25 @@ open class StringFeedViewModel( } } - var collectorJob: Job? = null - init { Log.d("Init", this.javaClass.simpleName) - collectorJob = - viewModelScope.launch(Dispatchers.IO) { - checkNotInMainThread() - - LocalCache.live.newEventBundles.collect { newNotes -> - checkNotInMainThread() - - invalidateData() - } + viewModelScope.launch(Dispatchers.Default) { + LocalCache.live.newEventBundles.collect { newNotes -> + Log.d("Rendering Metrics", "Update feeds: ${this@StringFeedViewModel.javaClass.simpleName} with ${newNotes.size}") + invalidateData() } + } + viewModelScope.launch(Dispatchers.Default) { + LocalCache.live.deletedEventBundles.collect { newNotes -> + Log.d("Rendering Metrics", "Update feeds: ${this@StringFeedViewModel.javaClass.simpleName} with ${newNotes.size}") + invalidateData() + } + } } override fun onCleared() { Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") bundler.cancel() - collectorJob?.cancel() super.onCleared() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt new file mode 100644 index 0000000000..60d35bcfc0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt @@ -0,0 +1,147 @@ +/** + * 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.ui.screen.loggedIn.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow +import java.util.Locale as JavaLocale + +@Preview(device = "spec:width=2160px,height=2340px,dpi=440") +@Composable +fun UserSettingsScreenPreview() { + val accountViewModel = mockAccountViewModel() + val nav = EmptyNav + ThemeComparisonRow { + UserSettingsScreen(accountViewModel, nav) + } +} + +@Composable +fun UserSettingsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + TopBarWithBackButton(stringRes(id = R.string.user_preferences), nav::popBack) + }, + accountViewModel = accountViewModel, + ) { + Column(Modifier.padding(it)) { + Column( + Modifier + .fillMaxSize() + .padding(top = Size10dp, start = Size20dp, end = Size20dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + DontTranslateFromSetting(accountViewModel) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DontTranslateFromSetting(accountViewModel: AccountViewModel) { + var expanded by remember { mutableStateOf(false) } + val selectedLanguages = accountViewModel.dontTranslateFromFilteredBySpokenLanguages().toMutableSet() + + Column { + SettingsRow( + name = R.string.dont_translate_from, + description = R.string.dont_translate_from_description, + ) { + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + ) { + OutlinedTextField( + value = stringRes(R.string.quick_action_select), + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryEditable), + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + selectedLanguages.forEach { languageCode -> + DropdownMenuItem( + text = { Text(text = JavaLocale.forLanguageTag(languageCode).displayName) }, + onClick = { + accountViewModel.toggleDontTranslateFrom(languageCode) + selectedLanguages.remove(languageCode) + expanded = false + }, + trailingIcon = { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Remove $languageCode", + tint = Color.Red, + modifier = Modifier.size(16.dp), + ) + }, + ) + } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HiddenAccountsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenAccountsFeedFilter.kt similarity index 69% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HiddenAccountsFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenAccountsFeedFilter.kt index 1a04da1668..b1c001bb8b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HiddenAccountsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenAccountsFeedFilter.kt @@ -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,12 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal import android.util.Log import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.FeedFilter import kotlinx.coroutines.CancellationException class HiddenAccountsFeedFilter( @@ -34,7 +35,7 @@ class HiddenAccountsFeedFilter( override fun showHiddenKey(): Boolean = true override fun feed(): List = - account.flowHiddenUsers.value.hiddenUsers.reversed().mapNotNull { + account.hiddenUsers.flow.value.hiddenUsers.reversed().mapNotNull { try { LocalCache.getOrCreateUser(it) } catch (e: Exception) { @@ -44,25 +45,3 @@ class HiddenAccountsFeedFilter( } } } - -class HiddenWordsFeedFilter( - val account: Account, -) : FeedFilter() { - override fun feedKey(): String = account.userProfile().pubkeyHex - - override fun showHiddenKey(): Boolean = true - - override fun feed(): List = - account.flowHiddenUsers.value.hiddenWords - .toList() -} - -class SpammerAccountsFeedFilter( - val account: Account, -) : FeedFilter() { - override fun feedKey(): String = account.userProfile().pubkeyHex - - override fun showHiddenKey(): Boolean = true - - override fun feed(): List = account.transientHiddenUsers.value.map { LocalCache.getOrCreateUser(it) } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenAccountsFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenAccountsFeedViewModel.kt new file mode 100644 index 0000000000..7c6bc44885 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenAccountsFeedViewModel.kt @@ -0,0 +1,42 @@ +/** + * 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.ui.screen.loggedIn.settings.dal + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel + +class HiddenAccountsFeedViewModel( + val account: Account, +) : UserFeedViewModel(HiddenAccountsFeedFilter(account)) { + class Factory( + val account: Account, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(HiddenAccountsFeedViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return HiddenAccountsFeedViewModel(account) as T + } + throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenWordsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenWordsFeedFilter.kt new file mode 100644 index 0000000000..ec9b509767 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenWordsFeedFilter.kt @@ -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.ui.screen.loggedIn.settings.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.dal.FeedFilter + +class HiddenWordsFeedFilter( + val account: Account, +) : FeedFilter() { + override fun feedKey(): String = account.userProfile().pubkeyHex + + override fun showHiddenKey(): Boolean = true + + override fun feed(): List = + account.hiddenUsers.flow.value.hiddenWords + .toList() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenWordsFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenWordsFeedViewModel.kt new file mode 100644 index 0000000000..2a2186990a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenWordsFeedViewModel.kt @@ -0,0 +1,39 @@ +/** + * 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.ui.screen.loggedIn.settings.dal + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.StringFeedViewModel + +class HiddenWordsFeedViewModel( + val account: Account, +) : StringFeedViewModel( + HiddenWordsFeedFilter(account), + ) { + class Factory( + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = HiddenWordsFeedViewModel(account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/SpammerAccountsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/SpammerAccountsFeedFilter.kt new file mode 100644 index 0000000000..542fa1f921 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/SpammerAccountsFeedFilter.kt @@ -0,0 +1,38 @@ +/** + * 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.ui.screen.loggedIn.settings.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.FeedFilter + +class SpammerAccountsFeedFilter( + val account: Account, +) : FeedFilter() { + override fun feedKey(): String = account.userProfile().pubkeyHex + + override fun showHiddenKey(): Boolean = true + + override fun feed(): List = + account.hiddenUsers.transientHiddenUsers.value + .map { LocalCache.getOrCreateUser(it) } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/SpammerAccountsFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/SpammerAccountsFeedViewModel.kt new file mode 100644 index 0000000000..f8a827b3f7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/SpammerAccountsFeedViewModel.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel + +class SpammerAccountsFeedViewModel( + val account: Account, +) : UserFeedViewModel(SpammerAccountsFeedFilter(account)) { + class Factory( + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = SpammerAccountsFeedViewModel(account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index bffcd62bcd..318672bd9d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -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 @@ -59,7 +59,6 @@ import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization @@ -73,18 +72,21 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid import com.vitorpamplona.amethyst.ui.components.GenericLoadable -import com.vitorpamplona.amethyst.ui.components.InlineCarrousel import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status +import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.feeds.FeedState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.routeFor -import com.vitorpamplona.amethyst.ui.navigation.routeToMessage +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage import com.vitorpamplona.amethyst.ui.note.CheckAndDisplayEditStatus import com.vitorpamplona.amethyst.ui.note.CheckHiddenFeedWatchBlockAndReport import com.vitorpamplona.amethyst.ui.note.DisplayDraft @@ -101,6 +103,7 @@ import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent import com.vitorpamplona.amethyst.ui.note.calculateBackgroundColor import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.DisplayZapSplits import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground import com.vitorpamplona.amethyst.ui.note.elements.DisplayFollowingCommunityInPost import com.vitorpamplona.amethyst.ui.note.elements.DisplayFollowingHashtagsInPost import com.vitorpamplona.amethyst.ui.note.elements.DisplayLocation @@ -115,11 +118,17 @@ import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.note.types.AudioHeader import com.vitorpamplona.amethyst.ui.note.types.AudioTrackHeader import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay +import com.vitorpamplona.amethyst.ui.note.types.DisplayBlockedRelayList +import com.vitorpamplona.amethyst.ui.note.types.DisplayBroadcastRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayDMRelayList +import com.vitorpamplona.amethyst.ui.note.types.DisplayFollowList +import com.vitorpamplona.amethyst.ui.note.types.DisplayIndexerRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayNIP65RelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayPeopleList +import com.vitorpamplona.amethyst.ui.note.types.DisplayProxyRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayRelaySet import com.vitorpamplona.amethyst.ui.note.types.DisplaySearchRelayList +import com.vitorpamplona.amethyst.ui.note.types.DisplayTrustedRelayList import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay @@ -139,17 +148,20 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent import com.vitorpamplona.amethyst.ui.note.types.RenderPoll import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval import com.vitorpamplona.amethyst.ui.note.types.RenderPrivateMessage +import com.vitorpamplona.amethyst.ui.note.types.RenderPublicMessage import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent import com.vitorpamplona.amethyst.ui.note.types.RenderTorrent import com.vitorpamplona.amethyst.ui.note.types.RenderTorrentComment import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay -import com.vitorpamplona.amethyst.ui.screen.LevelFeedViewModel +import com.vitorpamplona.amethyst.ui.note.types.VoiceHeader +import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.RenderFeedState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ChannelHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal.LevelFeedViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer @@ -159,7 +171,10 @@ import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.amethyst.ui.theme.imageModifier import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.selectedNote @@ -171,10 +186,11 @@ import com.vitorpamplona.quartz.experimental.forks.forkFromAddress import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableKind -import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash +import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip13Pow.strongPoWOrNull import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent @@ -194,9 +210,15 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip37Drafts.DraftEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent -import com.vitorpamplona.quartz.nip51Lists.RelaySetEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup @@ -210,6 +232,7 @@ import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking @@ -434,9 +457,9 @@ private fun FullBleedNoteCompose( ) { NoteAuthorPicture( baseNote = baseNote, - nav = nav, - accountViewModel = accountViewModel, size = Size55dp, + accountViewModel = accountViewModel, + nav = nav, ) Column(modifier = Modifier.padding(start = 10.dp)) { @@ -469,9 +492,9 @@ private fun FullBleedNoteCompose( nav, ) - val geo = remember { noteEvent.getGeoHash() } + val geo = remember { noteEvent.geoHashOrScope() } if (geo != null) { - DisplayLocation(geo, nav) + DisplayLocation(geo, accountViewModel, nav) } val baseReward = remember { noteEvent.bountyBaseReward()?.let { Reward(it) } } @@ -496,8 +519,8 @@ private fun FullBleedNoteCompose( Spacer(modifier = Modifier.height(10.dp)) when (noteEvent) { - is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote) - is LongTextNoteEvent -> RenderLongFormHeaderForThread(noteEvent) + is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote, accountViewModel) + is LongTextNoteEvent -> RenderLongFormHeaderForThread(noteEvent, baseNote, accountViewModel) is WikiNoteEvent -> RenderWikiHeaderForThread(noteEvent, accountViewModel, nav) is ClassifiedsEvent -> RenderClassifiedsReaderForThread(noteEvent, baseNote, accountViewModel, nav) } @@ -508,27 +531,36 @@ private fun FullBleedNoteCompose( .padding(horizontal = 12.dp), ) { Column { - if ( - (noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) && - baseNote.channelHex() != null - ) { - ChannelHeader( - channelHex = baseNote.channelHex()!!, - showVideo = true, + if (noteEvent is ChannelCreateEvent) { + PublicChatChannelHeader( + channelHex = noteEvent.id, sendToChannel = true, accountViewModel = accountViewModel, nav = nav, ) + } else if (noteEvent is ChannelMetadataEvent) { + noteEvent.channelId()?.let { + PublicChatChannelHeader( + channelHex = it, + sendToChannel = true, + accountViewModel = accountViewModel, + nav = nav, + ) + } } else if (noteEvent is VideoEvent) { VideoDisplay(baseNote, makeItShort = false, canPreview = true, backgroundColor = backgroundColor, ContentScale.FillWidth, accountViewModel = accountViewModel, nav = nav) } else if (noteEvent is PictureEvent) { PictureDisplay(baseNote, roundedCorner = true, ContentScale.FillWidth, PaddingValues(vertical = Size5dp), backgroundColor, accountViewModel = accountViewModel, nav) + } else if (noteEvent is BaseVoiceEvent) { + VoiceHeader(noteEvent, baseNote, ContentScale.FillWidth, accountViewModel, nav) } else if (noteEvent is FileHeaderEvent) { FileHeaderDisplay(baseNote, roundedCorner = true, ContentScale.FillWidth, accountViewModel = accountViewModel) } else if (noteEvent is FileStorageHeaderEvent) { FileStorageHeaderDisplay(baseNote, roundedCorner = true, ContentScale.FillWidth, accountViewModel = accountViewModel) } else if (noteEvent is PeopleListEvent) { DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav) + } else if (noteEvent is FollowListEvent) { + DisplayFollowList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is AudioTrackEvent) { AudioTrackHeader(noteEvent, baseNote, ContentScale.FillWidth, accountViewModel, nav) } else if (noteEvent is AudioHeaderEvent) { @@ -579,6 +611,16 @@ private fun FullBleedNoteCompose( DisplayNIP65RelayList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is SearchRelayListEvent) { DisplaySearchRelayList(baseNote, backgroundColor, accountViewModel, nav) + } else if (noteEvent is BlockedRelayListEvent) { + DisplayBlockedRelayList(baseNote, backgroundColor, accountViewModel, nav) + } else if (noteEvent is ProxyRelayListEvent) { + DisplayProxyRelayList(baseNote, backgroundColor, accountViewModel, nav) + } else if (noteEvent is TrustedRelayListEvent) { + DisplayTrustedRelayList(baseNote, backgroundColor, accountViewModel, nav) + } else if (noteEvent is IndexerRelayListEvent) { + DisplayIndexerRelayList(baseNote, backgroundColor, accountViewModel, nav) + } else if (noteEvent is BroadcastRelayListEvent) { + DisplayBroadcastRelayList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is FhirResourceEvent) { RenderFhirResource(baseNote, accountViewModel, nav) } else if (noteEvent is GitRepositoryEvent) { @@ -603,6 +645,8 @@ private fun FullBleedNoteCompose( RenderDraft(baseNote, 3, true, backgroundColor, accountViewModel, nav) } else if (noteEvent is HighlightEvent) { RenderHighlight(baseNote, false, canPreview, quotesLeft = 3, backgroundColor, accountViewModel, nav) + } else if (noteEvent is PublicMessageEvent) { + RenderPublicMessage(baseNote, false, canPreview, quotesLeft = 3, backgroundColor, accountViewModel, nav) } else if (noteEvent is CommentEvent) { RenderTextEvent( baseNote, @@ -726,7 +770,19 @@ private fun RenderClassifiedsReaderForThread( accountViewModel: AccountViewModel, nav: INav, ) { - val images = remember(noteEvent) { noteEvent.images().toImmutableList() } + val imageSet = + noteEvent.imageMetas().ifEmpty { null }?.map { + MediaUrlImage( + url = it.url, + description = it.alt, + hash = it.hash, + blurhash = it.blurhash, + dim = it.dimension, + uri = note.toNostrUri(), + mimeType = it.mimeType, + ) + } + val title = remember(noteEvent) { noteEvent.title() } val summary = remember(noteEvent) { @@ -742,11 +798,14 @@ private fun RenderClassifiedsReaderForThread( Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp)) { Column { - if (images.isNotEmpty()) { - Row { - InlineCarrousel( - images, - images.first(), + if (imageSet != null && imageSet.isNotEmpty()) { + AutoNonlazyGrid(imageSet.size) { + ZoomableContentView( + content = imageSet[it], + images = imageSet.toImmutableList(), + roundedCorner = false, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, ) } } else { @@ -824,7 +883,7 @@ private fun RenderClassifiedsReaderForThread( verticalAlignment = Alignment.CenterVertically, ) { Icon( - painter = painterResource(R.drawable.ic_dm), + painter = painterRes(R.drawable.ic_dm, 5), stringRes(R.string.send_a_direct_message), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary, @@ -901,19 +960,29 @@ private fun RenderClassifiedsReaderForThread( } @Composable -private fun RenderLongFormHeaderForThread(noteEvent: LongTextNoteEvent) { +private fun RenderLongFormHeaderForThread( + noteEvent: LongTextNoteEvent, + note: Note, + accountViewModel: AccountViewModel, +) { Column(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp)) { noteEvent.image()?.let { - AsyncImage( - model = it, + MyAsyncImage( + imageUrl = it, contentDescription = stringRes( R.string.preview_card_image_for, it, ), contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth(), + mainImageModifier = Modifier, + loadedImageModifier = MaterialTheme.colorScheme.imageModifier, + accountViewModel = accountViewModel, + onLoadingBackground = { DefaultImageHeaderBackground(note, accountViewModel) }, + onError = { DefaultImageHeader(note, accountViewModel) }, ) + } ?: run { + DefaultImageHeader(note, accountViewModel) } noteEvent.title()?.let { @@ -930,11 +999,12 @@ private fun RenderLongFormHeaderForThread(noteEvent: LongTextNoteEvent) { .summary() ?.ifBlank { null } ?.let { - Spacer(modifier = DoubleVertSpacer) + Spacer(modifier = StdVertSpacer) Text( text = it, modifier = Modifier.fillMaxWidth(), - color = Color.Gray, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, ) } } @@ -954,7 +1024,7 @@ private fun RenderWikiHeaderForThreadPreview() { runBlocking { withContext(Dispatchers.IO) { - LocalCache.justConsume(event, null) + LocalCache.justConsume(event, null, false) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadScreen.kt index 5bb86f7e5f..4ee2396a12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadScreen.kt @@ -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,21 +24,18 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.service.NostrThreadDataSource +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.components.LoadNote -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton -import com.vitorpamplona.amethyst.ui.screen.NostrThreadFeedViewModel +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal.ThreadFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.stringRes @Composable @@ -49,48 +46,19 @@ fun ThreadScreen( ) { if (noteId == null) return - val lifeCycleOwner = LocalLifecycleOwner.current - - val feedViewModel: NostrThreadFeedViewModel = + val feedViewModel: ThreadFeedViewModel = viewModel( key = noteId + "NostrThreadFeedViewModel", - factory = NostrThreadFeedViewModel.Factory(accountViewModel.account, noteId), + factory = ThreadFeedViewModel.Factory(accountViewModel.account, noteId), ) - NostrThreadDataSource.loadThread(noteId) - - DisposableEffect(noteId) { - feedViewModel.invalidateData(true) - onDispose { - NostrThreadDataSource.loadThread(null) - NostrThreadDataSource.stop() - } - } - - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Thread Start") - NostrThreadDataSource.loadThread(noteId) - NostrThreadDataSource.start() - feedViewModel.invalidateData(true) - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Thread Stop") - NostrThreadDataSource.loadThread(null) - NostrThreadDataSource.stop() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } + WatchLifecycleAndUpdateModel(feedViewModel) + ThreadFilterAssemblerSubscription(noteId, accountViewModel) LoadNote(noteId, accountViewModel) { if (it != null) { // this will force loading every post from this thread. - val metadata = it.live().metadata.observeAsState() + EventFinderFilterAssemblerSubscription(it, accountViewModel) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/dal/LevelFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/dal/LevelFeedViewModel.kt new file mode 100644 index 0000000000..2a0115802a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/dal/LevelFeedViewModel.kt @@ -0,0 +1,95 @@ +/** + * 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.ui.screen.loggedIn.threadview.dal + +import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.ThreadLevelCalculator +import com.vitorpamplona.amethyst.ui.dal.FeedFilter +import com.vitorpamplona.amethyst.ui.feeds.FeedState +import com.vitorpamplona.amethyst.ui.screen.FeedViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest + +abstract class LevelFeedViewModel( + localFilter: FeedFilter, +) : FeedViewModel(localFilter) { + var llState: LazyListState by mutableStateOf(LazyListState(0, 0)) + + val hasDragged = mutableStateOf(false) + + val selectedIDHex = + llState.interactionSource.interactions + .onEach { + if (it is DragInteraction.Start) { + hasDragged.value = true + } + }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + null, + ) + + @OptIn(ExperimentalCoroutinesApi::class) + val levelCacheFlow: StateFlow> = + feedState.feedContent + .transformLatest { feed -> + emitAll( + if (feed is FeedState.Loaded) { + feed.feed.map { + val cache = mutableMapOf() + it.list.forEach { + ThreadLevelCalculator.replyLevel(it, cache) + } + cache + } + } else { + MutableStateFlow(mapOf()) + }, + ) + }.flowOn(Dispatchers.Default) + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + mapOf(), + ) + + fun levelFlowForItem(note: Note) = + levelCacheFlow + .map { + it[note] ?: 0 + }.distinctUntilChanged() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/dal/ThreadFeedFilter.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/dal/ThreadFeedFilter.kt index bc80e6fb2d..f1ae4c13a8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/dal/ThreadFeedFilter.kt @@ -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,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.Account @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.model.LevelSignature import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.ThreadAssembler import com.vitorpamplona.amethyst.model.ThreadLevelCalculator +import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.toImmutableSet @@ -38,7 +39,7 @@ class ThreadFeedFilter( override fun feed(): List { val cachedSignatures: MutableMap = mutableMapOf() - val followingKeySet = account.liveKind3Follows.value.authors + val followingKeySet = account.kind3FollowList.flow.value.authors val eventsToWatch = ThreadAssembler().findThreadFor(noteId) ?: return emptyList() // Filter out drafts made by other accounts on device diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/dal/ThreadFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/dal/ThreadFeedViewModel.kt new file mode 100644 index 0000000000..66db36a8d0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/dal/ThreadFeedViewModel.kt @@ -0,0 +1,38 @@ +/** + * 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.ui.screen.loggedIn.threadview.dal + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account + +class ThreadFeedViewModel( + account: Account, + noteId: String, +) : LevelFeedViewModel(ThreadFeedFilter(account, noteId)) { + class Factory( + val account: Account, + val noteId: String, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = ThreadFeedViewModel(account, noteId) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssembler.kt new file mode 100644 index 0000000000..1ac82c64e2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssembler.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies.ThreadEventLoaderSubAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies.ThreadFilterSubAssembler +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient + +// This allows multiple screen to be listening to tags, even the same tag +class ThreadQueryState( + val eventId: HexKey, + val account: Account, +) + +class ThreadFilterAssembler( + client: NostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + ThreadFilterSubAssembler(client, ::allKeys), + ThreadEventLoaderSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } + + override fun printStats() = group.forEach { it.printStats() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..7954bda887 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssemblerSubscription.kt @@ -0,0 +1,54 @@ +/** + * 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.ui.screen.loggedIn.threadview.datasources + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +@Composable +fun ThreadFilterAssemblerSubscription( + eventId: HexKey, + accountViewModel: AccountViewModel, +) = ThreadFilterAssemblerSubscription( + eventId, + accountViewModel.account, + accountViewModel.dataSources().thread, +) + +@Composable +fun ThreadFilterAssemblerSubscription( + eventId: HexKey, + account: Account, + filterAssembler: ThreadFilterAssembler, +) { + // different screens get different states + // even if they are tracking the same tag. + val state = + remember(eventId) { + ThreadQueryState(eventId, account) + } + + KeyDataSourceSubscription(state, filterAssembler) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterEventsInThreadForRoot.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterEventsInThreadForRoot.kt new file mode 100644 index 0000000000..c0cabe9fff --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterEventsInThreadForRoot.kt @@ -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.ui.screen.loggedIn.threadview.datasources.subassembies + +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter + +fun filterEventsInThreadForRoot( + root: Note, + since: SincePerRelayMap?, +): List { + val addressRoot = if (root is AddressableNote) root.idHex else null + val eventRoot = if (root !is AddressableNote) root.idHex else root.event?.id + + return root.relayUrlsForReactions().toSet().flatMap { + val since = since?.get(it)?.time + + val addressList = + if (addressRoot != null) { + listOf( + RelayBasedFilter( + relay = it, + filter = + Filter( + tags = mapOf("a" to listOf(addressRoot)), + since = since, + ), + ), + RelayBasedFilter( + relay = it, + filter = + Filter( + tags = mapOf("A" to listOf(addressRoot)), + since = since, + ), + ), + ) + } else { + emptyList() + } + + val eventList = + if (eventRoot != null) { + listOf( + RelayBasedFilter( + relay = it, + filter = + Filter( + tags = mapOf("e" to listOf(eventRoot)), + since = since, + ), + ), + RelayBasedFilter( + relay = it, + filter = + Filter( + tags = mapOf("E" to listOf(eventRoot)), + since = since, + ), + ), + ) + } else { + emptyList() + } + + addressList + eventList + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterMissingEventsForThread.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterMissingEventsForThread.kt new file mode 100644 index 0000000000..8e0ad15d1c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterMissingEventsForThread.kt @@ -0,0 +1,75 @@ +/** + * 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.ui.screen.loggedIn.threadview.datasources.subassembies + +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.ThreadAssembler +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingAddressables +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingEvents +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindAddress +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.mapOfSet + +fun filterMissingEventsForThread( + threadInfo: ThreadAssembler.ThreadInfo, + defaultRelays: Set, +): List { + val missingEvents = + mapOfSet { + if (threadInfo.root.event == null && threadInfo.root !is AddressableNote) { + potentialRelaysToFindEvent(threadInfo.root).ifEmpty { defaultRelays }.forEach { relayUrl -> + add(relayUrl, threadInfo.root.idHex) + } + } + + threadInfo.allNotes.forEach { + if (it !is AddressableNote && it.event == null) { + potentialRelaysToFindEvent(it).ifEmpty { defaultRelays }.forEach { relayUrl -> + add(relayUrl, it.idHex) + } + } + } + } + + val missingAddresses = + mapOfSet { + if (threadInfo.root.event == null && threadInfo.root is AddressableNote) { + potentialRelaysToFindEvent(threadInfo.root).ifEmpty { defaultRelays }.forEach { relayUrl -> + add(relayUrl, threadInfo.root.address) + } + } + + threadInfo.allNotes.forEach { + if (it is AddressableNote && it.event == null) { + potentialRelaysToFindAddress(it).ifEmpty { defaultRelays }.forEach { relayUrl -> + add(relayUrl, it.address) + } + } + } + } + + val missingEventsFilter = filterMissingEvents(missingEvents) + val missingAddressFilter = filterMissingAddressables(missingAddresses) + + return missingEventsFilter + missingAddressFilter +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/ThreadEventLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/ThreadEventLoaderSubAssembler.kt new file mode 100644 index 0000000000..cc3e8a12c3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/ThreadEventLoaderSubAssembler.kt @@ -0,0 +1,54 @@ +/** + * 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.ui.screen.loggedIn.threadview.datasources.subassembies + +import com.vitorpamplona.amethyst.model.ThreadAssembler +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadQueryState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +/** + * Loads all missing events in each thread. + * + * This rotates really fast with the goal to get to zero + * events needed as fast as possible + * + * Each root has a relay subscription subscription for itself and + * saves the of the thread EOSEs in the long run to avoid re-downloading + */ +class ThreadEventLoaderSubAssembler( + client: NostrClient, + allKeys: () -> Set, +) : PerUniqueIdEoseManager(client, allKeys, invalidateAfterEose = true) { + override fun updateFilter( + key: ThreadQueryState, + since: SincePerRelayMap?, + ): List? { + val branches = ThreadAssembler().findThreadFor(key.eventId) ?: return null + val defaultRelays = key.account.followPlusAllMine.flow.value + return filterMissingEventsForThread(branches, defaultRelays) + } + + override fun id(key: ThreadQueryState) = key.eventId +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/ThreadFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/ThreadFilterSubAssembler.kt new file mode 100644 index 0000000000..f65a9d330b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/ThreadFilterSubAssembler.kt @@ -0,0 +1,51 @@ +/** + * 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.ui.screen.loggedIn.threadview.datasources.subassembies + +import com.vitorpamplona.amethyst.model.ThreadAssembler +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadQueryState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +/** + * Loads all events in the Thread that cites the root post. + * + * Each root has a relay subscription subscription for itself and + * saves the of the thread EOSEs in the long run to avoid re-downloading + */ +class ThreadFilterSubAssembler( + client: NostrClient, + allKeys: () -> Set, +) : PerUniqueIdEoseManager(client, allKeys) { + override fun updateFilter( + key: ThreadQueryState, + since: SincePerRelayMap?, + ): List? { + val root = ThreadAssembler().findRoot(key.eventId) ?: return null + + return filterEventsInThreadForRoot(root, since) + } + + override fun id(key: ThreadQueryState) = key.eventId +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/NewImageButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/NewImageButton.kt index f95a87de1a..c5bb56067d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/NewImageButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/NewImageButton.kt @@ -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 @@ -45,7 +45,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R @@ -54,9 +53,11 @@ import com.vitorpamplona.amethyst.ui.actions.NewMediaView import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.TakePicture -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size26Modifier import com.vitorpamplona.amethyst.ui.theme.Size55Modifier import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -178,7 +179,7 @@ fun NewImageButton( Icon( imageVector = Icons.Outlined.Close, contentDescription = stringRes(id = R.string.new_short), - modifier = Modifier.size(26.dp), + modifier = Size26Modifier, tint = Color.White, ) } @@ -189,9 +190,9 @@ fun NewImageButton( exit = fadeOut(), ) { Icon( - painter = painterResource(R.drawable.ic_compose), + painter = painterRes(R.drawable.ic_compose, 5), contentDescription = stringRes(id = R.string.new_short), - modifier = Modifier.size(26.dp), + modifier = Size26Modifier, tint = Color.White, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt index 68bb1cdce5..25f33b6006 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt @@ -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,25 +23,48 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.ui.navigation.FollowListWithRoutes -import com.vitorpamplona.amethyst.ui.navigation.GenericMainTopBar -import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner +import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar +import com.vitorpamplona.amethyst.ui.screen.FeedDefinition +import com.vitorpamplona.amethyst.ui.screen.FollowListState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes @Composable fun StoriesTopBar( accountViewModel: AccountViewModel, nav: INav, ) { - GenericMainTopBar(accountViewModel, nav) { + UserDrawerSearchTopBar(accountViewModel, nav) { val list by accountViewModel.account.settings.defaultStoriesFollowList .collectAsStateWithLifecycle() - FollowListWithRoutes( + FollowList( followListsModel = accountViewModel.feedStates.feedListOptions, listName = list, + accountViewModel = accountViewModel, ) { listName -> accountViewModel.account.settings.changeDefaultStoriesFollowList(listName.code) } } } + +@Composable +private fun FollowList( + followListsModel: FollowListState, + listName: String, + accountViewModel: AccountViewModel, + onChange: (FeedDefinition) -> Unit, +) { + val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle() + + FeedFilterSpinner( + placeholderCode = listName, + explainer = stringRes(R.string.select_list_to_filter), + options = allLists, + onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.kind3Follow) }, + accountViewModel = accountViewModel, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt index 934fb3f7d9..0d6e5aafe3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt @@ -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,6 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets @@ -40,7 +39,6 @@ import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -51,14 +49,10 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.NostrVideoDataSource import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status @@ -69,12 +63,14 @@ import com.vitorpamplona.amethyst.ui.feeds.FeedState import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState -import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route -import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.BoostReaction import com.vitorpamplona.amethyst.ui.note.CheckHiddenFeedWatchBlockAndReport import com.vitorpamplona.amethyst.ui.note.LikeReaction @@ -89,10 +85,11 @@ import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay import com.vitorpamplona.amethyst.ui.note.types.JustVideoDisplay import com.vitorpamplona.amethyst.ui.note.types.PictureDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.AuthorInfoVideoFeed import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.HalfFeedPadding import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size22Modifier import com.vitorpamplona.amethyst.ui.theme.Size35Modifier @@ -125,22 +122,9 @@ fun VideoScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val lifeCycleOwner = LocalLifecycleOwner.current - + WatchLifecycleAndUpdateModel(videoFeedContentState) WatchAccountForVideoScreen(videoFeedContentState = videoFeedContentState, accountViewModel = accountViewModel) - - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Video Start") - NostrVideoDataSource.start() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } + VideoFilterAssemblerSubscription(accountViewModel) DisappearingScaffold( isInvertedLayout = false, @@ -180,10 +164,11 @@ fun WatchAccountForVideoScreen( accountViewModel: AccountViewModel, ) { val listState by accountViewModel.account.liveStoriesFollowLists.collectAsStateWithLifecycle() - val hiddenUsers = accountViewModel.account.flowHiddenUsers.collectAsStateWithLifecycle() + val hiddenUsers = + accountViewModel.account.hiddenUsers.flow + .collectAsStateWithLifecycle() LaunchedEffect(accountViewModel, listState, hiddenUsers) { - NostrVideoDataSource.resetFilters() videoFeedContentState.checkKeysInvalidateDataAndSendToTop() } } @@ -296,8 +281,7 @@ private fun RenderVideoOrPictureNote( val noteEvent = remember { note.event } if (noteEvent is PictureEvent) { val backgroundColor = remember { mutableStateOf(Color.Transparent) } - - PictureDisplay(note, false, ContentScale.Fit, PaddingValues(5.dp), backgroundColor, accountViewModel, nav) + PictureDisplay(note, false, ContentScale.Fit, HalfFeedPadding, backgroundColor, accountViewModel, nav) } else if (noteEvent is FileHeaderEvent) { FileHeaderDisplay(note, false, ContentScale.Fit, accountViewModel) } else if (noteEvent is FileStorageHeaderEvent) { @@ -330,7 +314,7 @@ private fun RenderAuthorInformation( accountViewModel: AccountViewModel, ) { Row(modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 10.dp), verticalAlignment = Alignment.CenterVertically) { - NoteAuthorPicture(note, nav, accountViewModel, Size55dp) + NoteAuthorPicture(note, Size55dp, accountViewModel = accountViewModel, nav = nav) Spacer(modifier = DoubleHorzSpacer) @@ -437,7 +421,7 @@ fun ReactionsColumn( ) { routeFor( baseNote, - accountViewModel.userProfile(), + accountViewModel.account, )?.let { nav.nav(it) } } BoostReaction( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/SupportedContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/SupportedContent.kt new file mode 100644 index 0000000000..cd41fb2005 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/SupportedContent.kt @@ -0,0 +1,46 @@ +/** + * 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.ui.screen.loggedIn.video.dal + +class SupportedContent( + val blockedUrls: List, + val mimeTypes: Set, + val supportedFileExtensions: Set, +) { + private fun validExtension(fullUrl: String): Boolean { + val queryIndex = fullUrl.indexOf('?') + if (queryIndex > 0) { + return supportedFileExtensions.any { fullUrl.startsWith(it, queryIndex - it.length) } + } + + val fragmentIndex = fullUrl.indexOf('#') + if (fragmentIndex > 0) { + return supportedFileExtensions.any { fullUrl.startsWith(it, fragmentIndex - it.length) } + } + + return supportedFileExtensions.any { fullUrl.endsWith(it) } + } + + fun acceptableUrl( + url: String, + mimeType: String?, + ) = blockedUrls.none { url.contains(it) } && ((mimeType != null && mimeTypes.contains(mimeType)) || validExtension(url)) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/VideoFeedFilter.kt similarity index 73% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/VideoFeedFilter.kt index 565b2ba64d..b0744b1230 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/VideoFeedFilter.kt @@ -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,19 +18,23 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal -import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isImageOrVideoUrl +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.SUPPORTED_VIDEO_FEED_MIME_TYPES_SET +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.SUPPORTED_VIDEO_FEED_MIME_TYPES_SET import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip68Picture.PictureMeta import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoMeta import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent @@ -39,8 +43,17 @@ import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent class VideoFeedFilter( val account: Account, ) : AdditiveFeedFilter() { + val videoFeedSupport = + SupportedContent( + blockedUrls = listOf("youtu.be", "youtube.com"), + mimeTypes = SUPPORTED_VIDEO_FEED_MIME_TYPES_SET, + supportedFileExtensions = (RichTextParser.videoExtensions + RichTextParser.imageExtensions).toSet(), + ) + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultStoriesFollowList.value + override fun limit() = 300 + override fun showHiddenKey(): Boolean = account.settings.defaultStoriesFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || account.settings.defaultStoriesFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) @@ -70,25 +83,19 @@ class VideoFeedFilter( fun acceptableUrls( baseUrls: List, mimeType: String?, - ): Boolean { - // we don't have an youtube player - val urls = baseUrls.filter { !it.contains("youtu.be") && !it.contains("youtube.com") } + ) = baseUrls.any { videoFeedSupport.acceptableUrl(it, mimeType) } - val isSupportedMimeType = mimeType?.let { SUPPORTED_VIDEO_FEED_MIME_TYPES_SET.contains(it) } ?: false + fun acceptableVideoiMetas(iMetas: List): Boolean = iMetas.any { videoFeedSupport.acceptableUrl(it.url, it.mimeType) } - return urls.isNotEmpty() && (urls.any { isImageOrVideoUrl(it) } || isSupportedMimeType) - } + fun acceptablePictureiMetas(iMetas: List): Boolean = iMetas.any { videoFeedSupport.acceptableUrl(it.url, it.mimeType) } - fun acceptableiMetas(iMetas: List): Boolean = - iMetas.any { - !it.url.contains("youtu.be") && (isImageOrVideoUrl(it.url) || (it.mimeType == null || SUPPORTED_VIDEO_FEED_MIME_TYPES_SET.contains(it.mimeType))) - } + fun acceptanceEvent(noteEvent: PictureEvent) = acceptablePictureiMetas(noteEvent.imetaTags()) fun acceptanceEvent(noteEvent: FileHeaderEvent) = acceptableUrls(noteEvent.urls(), noteEvent.mimeType()) - fun acceptanceEvent(noteEvent: VideoVerticalEvent) = acceptableiMetas(noteEvent.imetaTags()) + fun acceptanceEvent(noteEvent: VideoVerticalEvent) = acceptableVideoiMetas(noteEvent.imetaTags()) - fun acceptanceEvent(noteEvent: VideoHorizontalEvent) = acceptableiMetas(noteEvent.imetaTags()) + fun acceptanceEvent(noteEvent: VideoHorizontalEvent) = acceptableVideoiMetas(noteEvent.imetaTags()) fun acceptableEvent( note: Note, @@ -105,7 +112,7 @@ class VideoFeedFilter( (noteEvent is VideoVerticalEvent && acceptanceEvent(noteEvent)) || (noteEvent is VideoHorizontalEvent && acceptanceEvent(noteEvent)) || (noteEvent is FileStorageHeaderEvent && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) || - noteEvent is PictureEvent + (noteEvent is PictureEvent && acceptanceEvent(noteEvent)) ) && params.match(noteEvent) && (params.isHiddenList || account.isAcceptable(note)) @@ -113,10 +120,8 @@ class VideoFeedFilter( fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( - userHex = account.userProfile().pubkeyHex, - selectedListName = account.settings.defaultStoriesFollowList.value, followLists = account.liveStoriesFollowLists.value, - hiddenUsers = account.flowHiddenUsers.value, + hiddenUsers = account.hiddenUsers.flow.value, ) override fun sort(collection: Set): List = collection.sortedWith(DefaultFeedOrder) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/FeedBasis.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/FeedBasis.kt new file mode 100644 index 0000000000..6915b01989 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/FeedBasis.kt @@ -0,0 +1,38 @@ +/** + * 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.ui.screen.loggedIn.video.datasource + +import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent +import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent + +val SUPPORTED_VIDEO_FEED_MIME_TYPES = listOf("image/jpeg", "image/gif", "image/png", "image/webp", "video/mp4", "video/mpeg", "video/webm", "audio/aac", "audio/mpeg", "audio/webm", "audio/wav", "image/avif") +val SUPPORTED_VIDEO_FEED_MIME_TYPES_SET = SUPPORTED_VIDEO_FEED_MIME_TYPES.toSet() + +val PictureAndVideoKinds = listOf(PictureEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND) +val PictureAndVideoKTags = listOf(PictureEvent.KIND.toString(), VideoHorizontalEvent.KIND.toString(), VideoVerticalEvent.KIND.toString()) + +val PictureAndVideoLegacyKinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND) +val PictureAndVideoLegacyKTags = listOf(FileHeaderEvent.KIND.toString(), FileStorageHeaderEvent.KIND.toString()) +val LegacyMimeTypes = SUPPORTED_VIDEO_FEED_MIME_TYPES +val LegacyMimeTypeMap = mapOf("m" to LegacyMimeTypes) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssembler.kt new file mode 100644 index 0000000000..11fba18b78 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssembler.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.VideoOutboxEventsFilterSubAssembler +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import kotlinx.coroutines.CoroutineScope + +// This allows multiple screen to be listening to tags, even the same tag +class VideoQueryState( + val account: Account, + val feedState: AccountFeedContentStates, + val scope: CoroutineScope, +) + +class VideoFilterAssembler( + client: NostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + VideoOutboxEventsFilterSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } + + override fun printStats() = group.forEach { it.printStats() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..691cb8dbbf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssemblerSubscription.kt @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun VideoFilterAssemblerSubscription(accountViewModel: AccountViewModel) { + VideoFilterAssemblerSubscription( + accountViewModel.dataSources().video, + accountViewModel, + ) +} + +@Composable +fun VideoFilterAssemblerSubscription( + filterAssembler: VideoFilterAssembler, + accountViewModel: AccountViewModel, +) { + // different screens get different states + // even if they are tracking the same tag. + val state = + remember(accountViewModel.account) { + VideoQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope) + } + + KeyDataSourceSubscription(state, filterAssembler) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoOutboxEventsFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoOutboxEventsFilterSubAssembler.kt new file mode 100644 index 0000000000..99714ded78 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoOutboxEventsFilterSubAssembler.kt @@ -0,0 +1,117 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoQueryState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip01Core.filterPictureAndVideoByGeohash +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip01Core.filterPictureAndVideoByHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip01Core.filterPictureAndVideoGlobal +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip65Follows.filterPictureAndVideoByAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip65Follows.filterPictureAndVideoByFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip72Communities.filterPictureAndVideoByAllCommunities +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip72Communities.filterPictureAndVideoByCommunity +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch + +class VideoOutboxEventsFilterSubAssembler( + client: NostrClient, + allKeys: () -> Set, +) : PerUserAndFollowListEoseManager(client, allKeys) { + override fun updateFilter( + key: VideoQueryState, + since: SincePerRelayMap?, + ): List? { + val feedSettings = key.followsPerRelay() + val defaultSince = key.feedState.videoFeed.lastNoteCreatedAtWhenFullyLoaded.value + return when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterPictureAndVideoByAllCommunities(feedSettings, since, defaultSince) + is AllFollowsTopNavPerRelayFilterSet -> filterPictureAndVideoByFollows(feedSettings, since, defaultSince) + is AuthorsTopNavPerRelayFilterSet -> filterPictureAndVideoByAuthors(feedSettings, since, defaultSince) + is GlobalTopNavPerRelayFilterSet -> filterPictureAndVideoGlobal(feedSettings, since, defaultSince) + is HashtagTopNavPerRelayFilterSet -> filterPictureAndVideoByHashtag(feedSettings, since, defaultSince) + is LocationTopNavPerRelayFilterSet -> filterPictureAndVideoByGeohash(feedSettings, since, defaultSince) + is MutedAuthorsTopNavPerRelayFilterSet -> filterPictureAndVideoByAuthors(feedSettings, since, defaultSince) + is SingleCommunityTopNavPerRelayFilterSet -> filterPictureAndVideoByCommunity(feedSettings, since, defaultSince) + else -> emptyList() + } + } + + override fun user(key: VideoQueryState) = key.account.userProfile() + + override fun list(key: VideoQueryState) = key.listName() + + fun VideoQueryState.listNameFlow() = account.settings.defaultStoriesFollowList + + fun VideoQueryState.listName() = listNameFlow().value + + fun VideoQueryState.followsPerRelayFlow() = account.liveStoriesFollowListsPerRelay + + fun VideoQueryState.followsPerRelay() = followsPerRelayFlow().value + + val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: VideoQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.scope.launch(Dispatchers.Default) { + key.followsPerRelayFlow().sample(1000).collectLatest { + invalidateFilters() + } + }, + key.scope.launch(Dispatchers.Default) { + key.feedState.videoFeed.lastNoteCreatedAtWhenFullyLoaded.sample(1000).collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByGeohash.kt new file mode 100644 index 0000000000..5cbc0db864 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByGeohash.kt @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip01Core + +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.LegacyMimeTypes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoKinds +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoLegacyKinds +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +fun filterPictureAndVideoGeohash( + relay: NormalizedRelayUrl, + geotags: Set, + since: Long? = null, +): List { + val geoHashes = geotags.sorted() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = PictureAndVideoKinds, + tags = mapOf("g" to geoHashes), + limit = 200, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = PictureAndVideoLegacyKinds, + tags = + mapOf( + "g" to geoHashes, + "m" to LegacyMimeTypes, + ), + limit = 200, + since = since, + ), + ), + ) +} + +fun filterPictureAndVideoByGeohash( + geoSet: LocationTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (geoSet.set.isEmpty()) return emptyList() + + return geoSet.set + .mapNotNull { + if (it.value.geotags.isEmpty()) { + null + } else { + filterPictureAndVideoGeohash( + relay = it.key, + geotags = it.value.geotags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByHashtag.kt new file mode 100644 index 0000000000..1b7bff4be4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByHashtag.kt @@ -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.ui.screen.loggedIn.video.datasource.subassemblies.nip01Core + +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.LegacyMimeTypes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoKinds +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoLegacyKinds +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts + +fun filterPictureAndVideoHashtag( + relay: NormalizedRelayUrl, + hashtags: Set, + since: Long? = null, +): List { + val hashtags = hashtags.flatMap(::hashtagAlts).distinct().sorted() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = PictureAndVideoKinds, + tags = mapOf("t" to hashtags), + limit = 100, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = PictureAndVideoLegacyKinds, + tags = + mapOf( + "t" to hashtags, + "m" to LegacyMimeTypes, + ), + limit = 100, + since = since, + ), + ), + ) +} + +fun filterPictureAndVideoByHashtag( + hashSet: HashtagTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (hashSet.set.isEmpty()) return emptyList() + + return hashSet.set + .mapNotNull { relayHashSet -> + if (relayHashSet.value.hashtags.isEmpty()) { + null + } else { + filterPictureAndVideoHashtag( + relay = relayHashSet.key, + hashtags = relayHashSet.value.hashtags, + since = since?.get(relayHashSet.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoGlobal.kt new file mode 100644 index 0000000000..a5c03b7ee6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoGlobal.kt @@ -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.ui.screen.loggedIn.video.datasource.subassemblies.nip01Core + +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.LegacyMimeTypeMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoKinds +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoLegacyKinds +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter + +fun filterPictureAndVideoGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (relays.set.isEmpty()) return emptyList() + + return relays.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + listOf( + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = PictureAndVideoKinds, + limit = 50, + since = since, + ), + ), + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = PictureAndVideoLegacyKinds, + tags = LegacyMimeTypeMap, + limit = 50, + since = since, + ), + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByAuthors.kt new file mode 100644 index 0000000000..29dc11fb22 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByAuthors.kt @@ -0,0 +1,106 @@ +/** + * 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.ui.screen.loggedIn.video.datasource.subassemblies.nip65Follows + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.LegacyMimeTypeMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoKinds +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoLegacyKinds +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlin.math.max + +fun filterPictureAndVideoAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = PictureAndVideoKinds, + limit = if (since == null) max(authorList.size * 20, 200) else null, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = PictureAndVideoLegacyKinds, + tags = LegacyMimeTypeMap, + limit = if (since == null) max(authorList.size * 20, 200) else null, + since = since, + ), + ), + ) +} + +fun filterPictureAndVideoByAuthors( + authorSet: AuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterPictureAndVideoAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterPictureAndVideoByAuthors( + authorSet: MutedAuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterPictureAndVideoAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByFollows.kt new file mode 100644 index 0000000000..368b25f72e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByFollows.kt @@ -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.ui.screen.loggedIn.video.datasource.subassemblies.nip65Follows + +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip01Core.filterPictureAndVideoGeohash +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip01Core.filterPictureAndVideoHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip72Communities.filterPictureAndVideoAllCommunities +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun filterPictureAndVideoByFollows( + followsSet: AllFollowsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (followsSet.set.isEmpty()) return emptyList() + + return followsSet.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + val relay = it.key + + listOfNotNull( + it.value.authors?.let { authors -> + filterPictureAndVideoAuthors(relay, authors, since) + }, + it.value.geotags?.let { geotags -> + filterPictureAndVideoGeohash(relay, geotags, since) + }, + it.value.hashtags?.let { hashtags -> + filterPictureAndVideoHashtag(relay, hashtags, since) + }, + it.value.communities?.let { communities -> + filterPictureAndVideoAllCommunities(relay, communities, since) + }, + ).flatten() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByAllCommunities.kt new file mode 100644 index 0000000000..b7be4a067f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByAllCommunities.kt @@ -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.ui.screen.loggedIn.video.datasource.subassemblies.nip72Communities + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.LegacyMimeTypes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoKTags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoKinds +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoLegacyKinds +import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent + +fun filterPictureAndVideoAllCommunities( + relay: NormalizedRelayUrl, + communities: Set, + since: Long? = null, +): List { + val communityList = communities.sorted() + + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to communityList, + "k" to PictureAndVideoKTags, + ), + limit = 200, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("a" to communityList), + kinds = PictureAndVideoKinds, + limit = 200, + since = since, + ), + ), + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to communityList, + "k" to listOf(FileHeaderEvent.KIND.toString(), FileStorageHeaderEvent.KIND.toString()), + ), + limit = 200, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = + mapOf( + "a" to communityList, + "m" to LegacyMimeTypes, + ), + kinds = PictureAndVideoLegacyKinds, + limit = 200, + since = since, + ), + ), + ) +} + +fun filterPictureAndVideoByAllCommunities( + communitySet: AllCommunitiesTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterPictureAndVideoAllCommunities( + relay = it.key, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByCommunity.kt new file mode 100644 index 0000000000..a5dc40bcbf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByCommunity.kt @@ -0,0 +1,122 @@ +/** + * 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.ui.screen.loggedIn.video.datasource.subassemblies.nip72Communities + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.LegacyMimeTypes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoKTags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoKinds +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoLegacyKTags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoLegacyKinds +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent + +fun filterPictureAndVideoCommunity( + relay: NormalizedRelayUrl, + community: String, + authors: Set?, + since: Long? = null, +): List { + val authors = authors?.sorted() + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to listOf(community), + "k" to PictureAndVideoKTags, + ), + limit = 200, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + tags = mapOf("a" to listOf(community)), + kinds = PictureAndVideoKinds, + limit = 200, + since = since, + ), + ), + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to listOf(community), + "k" to PictureAndVideoLegacyKTags, + ), + limit = 200, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + tags = + mapOf( + "a" to listOf(community), + "m" to LegacyMimeTypes, + ), + kinds = PictureAndVideoLegacyKinds, + limit = 200, + since = since, + ), + ), + ) +} + +fun filterPictureAndVideoByCommunity( + communitySet: SingleCommunityTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterPictureAndVideoCommunity( + relay = it.key, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/AcceptTerms.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/AcceptTerms.kt index 2ac1f49978..070847cb6e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/AcceptTerms.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/AcceptTerms.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/AddAccountDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/AddAccountDialog.kt index 75711ded47..c02dba01e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/AddAccountDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/AddAccountDialog.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginOrSignupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginOrSignupScreen.kt index 1494016eab..a1210e56a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginOrSignupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginOrSignupScreen.kt @@ -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,6 +27,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedOff.login.LoginPage +import com.vitorpamplona.amethyst.ui.screen.loggedOff.signup.SignUpPage @Composable fun LoginOrSignupScreen( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt deleted file mode 100644 index eb75146145..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt +++ /dev/null @@ -1,810 +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.ui.screen.loggedOff - -import android.app.Activity -import android.content.Intent -import android.util.Log -import android.widget.Toast -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Visibility -import androidx.compose.material.icons.outlined.VisibilityOff -import androidx.compose.material3.Button -import androidx.compose.material3.Checkbox -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -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.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.autofill.AutofillNode -import androidx.compose.ui.autofill.AutofillType -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.boundsInWindow -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalAutofill -import androidx.compose.ui.platform.LocalAutofillTree -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.core.util.Consumer -import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.hashtags.Amethyst -import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons -import com.vitorpamplona.amethyst.service.PackageUtils -import com.vitorpamplona.amethyst.ui.MainActivity -import com.vitorpamplona.amethyst.ui.components.LoadingAnimation -import com.vitorpamplona.amethyst.ui.components.getActivity -import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.Size0dp -import com.vitorpamplona.amethyst.ui.theme.Size10dp -import com.vitorpamplona.amethyst.ui.theme.Size20dp -import com.vitorpamplona.amethyst.ui.theme.Size35dp -import com.vitorpamplona.amethyst.ui.theme.Size40dp -import com.vitorpamplona.amethyst.ui.theme.Size50dp -import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow -import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.amethyst.ui.tor.TorSettings -import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser -import com.vitorpamplona.quartz.nip55AndroidSigner.ExternalSignerLauncher -import com.vitorpamplona.quartz.nip55AndroidSigner.SignerType -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import java.util.UUID - -@Preview(device = "spec:width=2160px,height=2340px,dpi=440") -@Composable -fun LoginPage() { - val accountViewModel: AccountStateViewModel = viewModel() - - ThemeComparisonRow( - toPreview = { - LoginPage(accountViewModel, true) {} - }, - ) -} - -@Composable -fun LoginPage( - accountStateViewModel: AccountStateViewModel, - isFirstLogin: Boolean, - newAccountKey: String? = null, - onWantsToLogin: () -> Unit, -) { - val key = remember { mutableStateOf(TextFieldValue(newAccountKey ?: "")) } - var errorMessage by remember { mutableStateOf("") } - val acceptedTerms = remember { mutableStateOf(!isFirstLogin) } - var termsAcceptanceIsRequired by remember { mutableStateOf("") } - - val context = LocalContext.current - val torSettings = remember { mutableStateOf(TorSettings()) } - val isNFCOrQR = remember { mutableStateOf(false) } - val isTemporary = remember { mutableStateOf(false) } - - val scope = rememberCoroutineScope() - var loginWithExternalSigner by remember { mutableStateOf(false) } - - var processingLogin by remember { mutableStateOf(false) } - - val password = remember { mutableStateOf(TextFieldValue("")) } - val needsPassword = - remember { - derivedStateOf { - key.value.text.startsWith("ncryptsec1") - } - } - - val passwordFocusRequester = remember { FocusRequester() } - - if (loginWithExternalSigner) { - PrepareExternalSignerReceiver { pubkey, packageName -> - key.value = TextFieldValue(pubkey) - if (!acceptedTerms.value) { - termsAcceptanceIsRequired = stringRes(context, R.string.acceptance_of_terms_is_required) - } - - if (key.value.text.isBlank()) { - errorMessage = stringRes(context, R.string.key_is_required) - } - - if (acceptedTerms.value && key.value.text.isNotBlank()) { - accountStateViewModel.login( - key = key.value.text, - torSettings = torSettings.value, - transientAccount = isTemporary.value, - loginWithExternalSigner = true, - packageName = packageName, - ) { - errorMessage = stringRes(context, R.string.invalid_key) - } - } - } - } - - Column( - modifier = - Modifier - .fillMaxSize() - .imePadding() - .verticalScroll(rememberScrollState()) - .padding(Size20dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Image( - imageVector = CustomHashTagIcons.Amethyst, - contentDescription = stringRes(R.string.app_logo), - modifier = Modifier.size(150.dp), - contentScale = ContentScale.Inside, - ) - - Spacer(modifier = Modifier.height(Size40dp)) - - KeyTextField( - value = key.value, - onValueChange = { value, isQr -> - key.value = value - if (isQr) { - isNFCOrQR.value = true - isTemporary.value = true - } - if (errorMessage.isNotEmpty()) { - errorMessage = "" - } - }, - ) { - if (!acceptedTerms.value) { - termsAcceptanceIsRequired = stringRes(context, R.string.acceptance_of_terms_is_required) - } - - if (key.value.text.isBlank()) { - errorMessage = stringRes(context, R.string.key_is_required) - } - - if (needsPassword.value && password.value.text.isBlank()) { - errorMessage = stringRes(context, R.string.password_is_required) - } - - if (acceptedTerms.value && key.value.text.isNotBlank() && !(needsPassword.value && password.value.text.isBlank())) { - processingLogin = true - accountStateViewModel.login( - key = key.value.text, - password = password.value.text, - torSettings = torSettings.value, - transientAccount = isTemporary.value, - ) { - processingLogin = false - errorMessage = - if (it != null) { - stringRes(context, R.string.invalid_key_with_message, it) - } else { - stringRes(context, R.string.invalid_key) - } - } - } - } - - if (errorMessage.isNotBlank()) { - Text( - text = errorMessage, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - ) - } - - Spacer(modifier = Modifier.height(10.dp)) - - if (needsPassword.value) { - PasswordField( - value = password.value, - onValueChange = { - password.value = it - if (errorMessage.isNotEmpty()) { - errorMessage = "" - } - }, - passwordFocusRequester, - ) { - if (!acceptedTerms.value) { - termsAcceptanceIsRequired = stringRes(context, R.string.acceptance_of_terms_is_required) - } - - if (key.value.text.isBlank()) { - errorMessage = stringRes(context, R.string.key_is_required) - } - - if (needsPassword.value && password.value.text.isBlank()) { - errorMessage = stringRes(context, R.string.password_is_required) - } - - if (acceptedTerms.value && key.value.text.isNotBlank() && !(needsPassword.value && password.value.text.isBlank())) { - processingLogin = true - accountStateViewModel.login(key.value.text, password.value.text, torSettings.value, isTemporary.value) { - processingLogin = false - errorMessage = - if (it != null) { - stringRes(context, R.string.invalid_key_with_message, it) - } else { - stringRes(context, R.string.invalid_key) - } - } - } - } - } - Spacer(modifier = Modifier.height(10.dp)) - - TorSettingsSetup( - torSettings = torSettings.value, - onCheckedChange = { - torSettings.value = it - }, - onError = { - scope.launch { - Toast - .makeText( - context, - it, - Toast.LENGTH_LONG, - ).show() - } - }, - ) - - if (isNFCOrQR.value) { - OfferTemporaryAccount( - checked = isTemporary.value, - onCheckedChange = { isTemporary.value = it }, - ) - } - - if (isFirstLogin) { - AcceptTerms( - checked = acceptedTerms.value, - onCheckedChange = { acceptedTerms.value = it }, - ) - - if (termsAcceptanceIsRequired.isNotBlank()) { - Text( - text = termsAcceptanceIsRequired, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - ) - } - } - - Spacer(modifier = Modifier.height(Size10dp)) - - Box(modifier = Modifier.padding(Size40dp, 0.dp, Size40dp, 0.dp)) { - LoginButton( - enabled = acceptedTerms.value, - processingLogin = processingLogin, - onClick = { - if (!acceptedTerms.value) { - termsAcceptanceIsRequired = - stringRes(context, R.string.acceptance_of_terms_is_required) - } - - if (key.value.text.isBlank()) { - errorMessage = stringRes(context, R.string.key_is_required) - } - - if (needsPassword.value && password.value.text.isBlank()) { - errorMessage = stringRes(context, R.string.password_is_required) - } - - if (acceptedTerms.value && key.value.text.isNotBlank() && !(needsPassword.value && password.value.text.isBlank())) { - processingLogin = true - accountStateViewModel.login(key.value.text, password.value.text, torSettings.value, isTemporary.value) { - processingLogin = false - errorMessage = - if (it != null) { - stringRes(context, R.string.invalid_key_with_message, it) - } else { - stringRes(context, R.string.invalid_key) - } - } - } - }, - ) - } - - if (PackageUtils.isExternalSignerInstalled(context)) { - Box(modifier = Modifier.padding(Size40dp, Size20dp, Size40dp, Size0dp)) { - LoginWithAmberButton( - enabled = acceptedTerms.value, - onClick = { - if (!acceptedTerms.value) { - termsAcceptanceIsRequired = stringRes(context, R.string.acceptance_of_terms_is_required) - } else { - loginWithExternalSigner = true - } - }, - ) - } - } - - Spacer(modifier = Modifier.height(Size40dp)) - - Text(text = stringRes(R.string.don_t_have_an_account)) - - Spacer(modifier = Modifier.height(Size20dp)) - - Box(modifier = Modifier.padding(Size40dp, 0.dp, Size40dp, 0.dp)) { - SignUpButton(onWantsToLogin) - } - } - - OpenURIIfNotLoggedIn { - key.value = TextFieldValue(it) - acceptedTerms.value = true - isNFCOrQR.value = true - isTemporary.value = true - if (it.startsWith("ncryptsec1")) { - delay(300) - passwordFocusRequester.requestFocus() - } - } -} - -@Composable -fun OfferTemporaryAccount( - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, -) { - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = checked, - onCheckedChange = onCheckedChange, - ) - - Text(stringRes(R.string.temporary_account)) - } -} - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -fun PasswordField( - value: TextFieldValue, - onValueChange: (TextFieldValue) -> Unit, - passwordFocusRequester: FocusRequester, - onGo: () -> Unit, -) { - val autofillNodeKey = - AutofillNode( - autofillTypes = listOf(AutofillType.Password), - onFill = { onValueChange(TextFieldValue(it)) }, - ) - - val autofillNodePassword = - AutofillNode( - autofillTypes = listOf(AutofillType.Password), - onFill = { onValueChange(TextFieldValue(it)) }, - ) - - val autofill = LocalAutofill.current - LocalAutofillTree.current += autofillNodeKey - LocalAutofillTree.current += autofillNodePassword - - var showCharsPassword by remember { mutableStateOf(false) } - OutlinedTextField( - modifier = - Modifier - .focusRequester(passwordFocusRequester) - .onGloballyPositioned { coordinates -> - autofillNodePassword.boundingBox = coordinates.boundsInWindow() - }.onFocusChanged { focusState -> - autofill?.run { - if (focusState.isFocused) { - requestAutofillForNode(autofillNodePassword) - } else { - cancelAutofillForNode(autofillNodePassword) - } - } - }, - value = value, - onValueChange = onValueChange, - keyboardOptions = - KeyboardOptions( - autoCorrectEnabled = false, - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Go, - ), - placeholder = { - Text( - text = stringRes(R.string.ncryptsec_password), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - trailingIcon = { - Row { - IconButton(onClick = { showCharsPassword = !showCharsPassword }) { - Icon( - imageVector = - if (showCharsPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, - contentDescription = - if (showCharsPassword) { - stringRes(R.string.show_password) - } else { - stringRes( - R.string.hide_password, - ) - }, - ) - } - } - }, - visualTransformation = - if (showCharsPassword) VisualTransformation.None else PasswordVisualTransformation(), - keyboardActions = - KeyboardActions( - onGo = { - onGo() - }, - ), - ) -} - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -fun KeyTextField( - value: TextFieldValue, - onValueChange: (TextFieldValue, throughQR: Boolean) -> Unit, - onLogin: () -> Unit, -) { - var dialogOpen by remember { mutableStateOf(false) } - - var showCharsKey by remember { mutableStateOf(false) } - - val autofillNodeKey = - AutofillNode( - autofillTypes = listOf(AutofillType.Password), - onFill = { onValueChange(TextFieldValue(it), false) }, - ) - - val autofillNodePassword = - AutofillNode( - autofillTypes = listOf(AutofillType.Password), - onFill = { onValueChange(TextFieldValue(it), false) }, - ) - - val autofill = LocalAutofill.current - LocalAutofillTree.current += autofillNodeKey - LocalAutofillTree.current += autofillNodePassword - - OutlinedTextField( - modifier = - Modifier - .onGloballyPositioned { coordinates -> - autofillNodeKey.boundingBox = coordinates.boundsInWindow() - }.onFocusChanged { focusState -> - autofill?.run { - if (focusState.isFocused) { - requestAutofillForNode(autofillNodeKey) - } else { - cancelAutofillForNode(autofillNodeKey) - } - } - }, - value = value, - onValueChange = { onValueChange(it, false) }, - keyboardOptions = - KeyboardOptions( - autoCorrectEnabled = false, - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Go, - ), - placeholder = { - Text( - text = stringRes(R.string.nsec_npub_hex_private_key), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - trailingIcon = { - Row { - IconButton(onClick = { showCharsKey = !showCharsKey }) { - Icon( - imageVector = - if (showCharsKey) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, - contentDescription = - if (showCharsKey) { - stringRes(R.string.show_password) - } else { - stringRes( - R.string.hide_password, - ) - }, - ) - } - } - }, - leadingIcon = { - if (dialogOpen) { - SimpleQrCodeScanner { - dialogOpen = false - if (!it.isNullOrEmpty()) { - onValueChange(TextFieldValue(it), true) - } - } - } - IconButton(onClick = { dialogOpen = true }) { - Icon( - painter = painterResource(R.drawable.ic_qrcode), - contentDescription = - stringRes( - R.string.login_with_qr_code, - ), - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary, - ) - } - }, - visualTransformation = - if (showCharsKey) VisualTransformation.None else PasswordVisualTransformation(), - keyboardActions = - KeyboardActions( - onGo = { - onLogin() - }, - ), - ) -} - -@Composable -private fun PrepareExternalSignerReceiver(onLogin: (pubkey: String, packageName: String) -> Unit) { - val scope = rememberCoroutineScope() - val externalSignerLauncher = remember { ExternalSignerLauncher("", signerPackageName = "") } - val id = remember { UUID.randomUUID().toString() } - - val launcher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult(), - onResult = { result -> - if (result.resultCode != Activity.RESULT_OK) { - scope.launch(Dispatchers.Main) { - Toast - .makeText( - Amethyst.instance, - "Sign request rejected", - Toast.LENGTH_SHORT, - ).show() - } - } else { - result.data?.let { externalSignerLauncher.newResult(it) } - } - }, - ) - - val activity = getActivity() as MainActivity - - DisposableEffect(launcher, activity, externalSignerLauncher) { - externalSignerLauncher.registerLauncher( - launcher = { - try { - launcher.launch(it) - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e("Signer", "Error opening Signer app", e) - scope.launch(Dispatchers.Main) { - Toast - .makeText( - Amethyst.instance, - R.string.error_opening_external_signer, - Toast.LENGTH_SHORT, - ).show() - } - } - }, - contentResolver = Amethyst.instance::contentResolverFn, - ) - onDispose { externalSignerLauncher.clearLauncher() } - } - - LaunchedEffect(externalSignerLauncher) { - externalSignerLauncher.openSignerApp( - "", - SignerType.GET_PUBLIC_KEY, - "", - id, - ) { result -> - val split = result.split("-") - val pubkey = split.first() - val packageName = if (split.size > 1) split[1] else "" - - onLogin(pubkey, packageName) - } - } -} - -@Composable -private fun OpenURIIfNotLoggedIn(onNewNIP19: suspend (String) -> Unit) { - val context = LocalContext.current - val activity = context.getActivity() - val scope = rememberCoroutineScope() - - var currentIntentNextPage by remember { - val uri = - activity.intent - ?.data - ?.toString() - ?.ifBlank { null } - - activity.intent.data = null - - mutableStateOf(uri) - } - - currentIntentNextPage?.let { intentNextPage -> - var nip19 by remember { - mutableStateOf( - Nip19Parser.tryParseAndClean(currentIntentNextPage), - ) - } - - LaunchedEffect(intentNextPage) { - if (nip19 != null) { - nip19?.let { - scope.launch { - onNewNIP19(it) - } - nip19 = null - } - } else { - scope.launch { - Toast - .makeText( - context, - stringRes(context, R.string.invalid_nip19_uri_description, intentNextPage), - Toast.LENGTH_SHORT, - ).show() - } - } - - currentIntentNextPage = null - } - } - - DisposableEffect(activity) { - val consumer = - Consumer { intent -> - val uri = intent.data?.toString() - if (!uri.isNullOrBlank()) { - val newNip19 = Nip19Parser.tryParseAndClean(uri) - if (newNip19 != null) { - scope.launch { - onNewNIP19(newNip19) - } - } else { - scope.launch { - delay(1000) - Toast - .makeText( - context, - stringRes(context, R.string.invalid_nip19_uri_description, uri), - Toast.LENGTH_SHORT, - ).show() - } - } - } - } - activity.addOnNewIntentListener(consumer) - onDispose { activity.removeOnNewIntentListener(consumer) } - } -} - -@Composable -fun SignUpButton(onClick: () -> Unit) { - OutlinedButton( - onClick = onClick, - shape = RoundedCornerShape(Size35dp), - modifier = Modifier.height(50.dp), - ) { - Text( - text = stringRes(R.string.sign_up), - modifier = Modifier.padding(horizontal = Size40dp), - ) - } -} - -@Composable -fun LoginWithAmberButton( - enabled: Boolean, - onClick: () -> Unit, -) { - Button( - enabled = enabled, - onClick = onClick, - shape = RoundedCornerShape(Size35dp), - modifier = Modifier.height(Size50dp), - ) { - Text( - text = stringRes(R.string.login_with_external_signer), - modifier = Modifier.padding(horizontal = Size40dp), - ) - } -} - -@Composable -fun LoginButton( - enabled: Boolean, - processingLogin: Boolean, - onClick: () -> Unit, -) { - Button( - enabled = enabled, - onClick = onClick, - shape = RoundedCornerShape(Size35dp), - modifier = Modifier.height(Size50dp), - ) { - Row(modifier = Modifier.padding(horizontal = Size40dp)) { - if (processingLogin) { - LoadingAnimation() - Spacer(modifier = DoubleHorzSpacer) - } - Text(stringRes(R.string.login)) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/TorSettingsSetup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/TorSettingsSetup.kt index 1757e2daa1..9e3ca242b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/TorSettingsSetup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/TorSettingsSetup.kt @@ -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 @@ -36,7 +36,6 @@ import com.vitorpamplona.amethyst.ui.components.appendLink import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.tor.ConnectTorDialog import com.vitorpamplona.amethyst.ui.tor.TorSettings -import com.vitorpamplona.amethyst.ui.tor.TorType @Composable fun TorSettingsSetup( @@ -45,8 +44,6 @@ fun TorSettingsSetup( onError: (String) -> Unit, ) { var connectOrbotDialogOpen by remember { mutableStateOf(false) } - var activeTor by remember { mutableStateOf(false) } - val primary = MaterialTheme.colorScheme.primary Text( @@ -63,7 +60,6 @@ fun TorSettingsSetup( torSettings = torSettings, onClose = { connectOrbotDialogOpen = false }, onPost = { torSettings -> - activeTor = torSettings.torType != TorType.OFF connectOrbotDialogOpen = false onCheckedChange(torSettings) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/ExternalSignerButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/ExternalSignerButton.kt new file mode 100644 index 0000000000..d3b2d274a2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/ExternalSignerButton.kt @@ -0,0 +1,84 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedOff.login + +import android.app.Activity +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.TextFieldValue +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.DefaultSignerPermissions +import com.vitorpamplona.amethyst.ui.theme.Size0dp +import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.Size40dp +import com.vitorpamplona.quartz.nip55AndroidSigner.api.PubKeyResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.client.ExternalSignerLogin +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch + +@Composable +fun ExternalSignerButton(loginViewModel: LoginViewModel) { + val scope = rememberCoroutineScope() + + val launcher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + ) { result -> + scope.launch { + val resultData = result.data + if (result.resultCode == Activity.RESULT_OK && resultData != null) { + val loginInfo = ExternalSignerLogin.parseResult(resultData) + if (loginInfo is SignerResult.RequestAddressed.Successful) { + loginViewModel.updateKey(TextFieldValue(loginInfo.result.pubkey), false) + loginViewModel.loginWithExternalSigner(loginInfo.result.packageName) + } + } else { + loginViewModel.errorManager.error(R.string.sign_request_rejected2) + } + } + } + + Box(modifier = Modifier.padding(Size40dp, Size20dp, Size40dp, Size0dp)) { + LoginWithAmberButton( + enabled = loginViewModel.acceptedTerms, + onClick = { + if (!loginViewModel.acceptedTerms) { + loginViewModel.termsAcceptanceIsRequiredError = true + } else { + try { + launcher.launch(ExternalSignerLogin.createIntent(DefaultSignerPermissions)) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("ExternalSigner", "Error opening Signer app", e) + loginViewModel.errorManager.error(R.string.error_opening_external_signer) + } + } + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/KeyTextField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/KeyTextField.kt new file mode 100644 index 0000000000..d0e73a6a64 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/KeyTextField.kt @@ -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.ui.screen.loggedOff.login + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.AutofillNode +import androidx.compose.ui.autofill.AutofillType +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalAutofill +import androidx.compose.ui.platform.LocalAutofillTree +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.painterRes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.placeholderText + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun KeyTextField( + value: TextFieldValue, + onValueChange: (TextFieldValue, throughQR: Boolean) -> Unit, + onLogin: () -> Unit, +) { + var dialogOpen by remember { mutableStateOf(false) } + + var showCharsKey by remember { mutableStateOf(false) } + + val autofillNodeKey = + AutofillNode( + autofillTypes = listOf(AutofillType.Password), + onFill = { onValueChange(TextFieldValue(it), false) }, + ) + + val autofillNodePassword = + AutofillNode( + autofillTypes = listOf(AutofillType.Password), + onFill = { onValueChange(TextFieldValue(it), false) }, + ) + + val autofill = LocalAutofill.current + LocalAutofillTree.current += autofillNodeKey + LocalAutofillTree.current += autofillNodePassword + + OutlinedTextField( + modifier = + Modifier + .onGloballyPositioned { coordinates -> + autofillNodeKey.boundingBox = coordinates.boundsInWindow() + }.onFocusChanged { focusState -> + autofill?.run { + if (focusState.isFocused) { + requestAutofillForNode(autofillNodeKey) + } else { + cancelAutofillForNode(autofillNodeKey) + } + } + }, + value = value, + onValueChange = { onValueChange(it, false) }, + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go, + ), + placeholder = { + Text( + text = stringRes(R.string.nsec_npub_hex_private_key), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + trailingIcon = { + Row { + IconButton(onClick = { showCharsKey = !showCharsKey }) { + Icon( + imageVector = + if (showCharsKey) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = + if (showCharsKey) { + stringRes(R.string.show_password) + } else { + stringRes( + R.string.hide_password, + ) + }, + ) + } + } + }, + leadingIcon = { + if (dialogOpen) { + SimpleQrCodeScanner { + dialogOpen = false + if (!it.isNullOrEmpty()) { + onValueChange(TextFieldValue(it), true) + } + } + } + IconButton(onClick = { dialogOpen = true }) { + Icon( + painter = painterRes(R.drawable.ic_qrcode, 5), + contentDescription = stringRes(R.string.login_with_qr_code), + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + visualTransformation = + if (showCharsKey) VisualTransformation.None else PasswordVisualTransformation(), + keyboardActions = + KeyboardActions( + onGo = { + onLogin() + }, + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginButton.kt new file mode 100644 index 0000000000..642ece51a5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginButton.kt @@ -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.ui.screen.loggedOff.login + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.components.LoadingAnimation +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.Size40dp +import com.vitorpamplona.amethyst.ui.theme.Size50dp + +@Composable +fun LoginButton( + enabled: Boolean, + processingLogin: Boolean, + onClick: () -> Unit, +) { + Button( + enabled = enabled, + onClick = onClick, + shape = RoundedCornerShape(Size35dp), + modifier = Modifier.height(Size50dp), + ) { + Row(modifier = Modifier.padding(horizontal = Size40dp)) { + if (processingLogin) { + LoadingAnimation() + Spacer(modifier = DoubleHorzSpacer) + } + Text(stringRes(R.string.login)) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginErrorManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginErrorManager.kt new file mode 100644 index 0000000000..9dc57b9d37 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginErrorManager.kt @@ -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.ui.screen.loggedOff.login + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +class LoginErrorManager { + interface IErrorMsg + + class SingleErrorMsg( + val errorResId: Int, + ) : IErrorMsg + + class ParamsErrorMsg( + val errorResId: Int, + val params: Array, + ) : IErrorMsg + + var error by mutableStateOf(null) + + fun clearErrors() { + error = null + } + + fun error(resourceId: Int) { + error = SingleErrorMsg(resourceId) + } + + fun error( + resourceId: Int, + vararg params: String, + ) { + error = ParamsErrorMsg(resourceId, params) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt new file mode 100644 index 0000000000..8329b0b94c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt @@ -0,0 +1,362 @@ +/** + * 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.ui.screen.loggedOff.login + +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.AutofillNode +import androidx.compose.ui.autofill.AutofillType +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalAutofill +import androidx.compose.ui.platform.LocalAutofillTree +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.hashtags.Amethyst +import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons +import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedOff.AcceptTerms +import com.vitorpamplona.amethyst.ui.screen.loggedOff.TorSettingsSetup +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.Size40dp +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip55AndroidSigner.client.isExternalSignerInstalled +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Preview(device = "spec:width=2160px,height=2340px,dpi=440") +@Composable +fun LoginPagePreview() { + val accountViewModel: AccountStateViewModel = viewModel() + + ThemeComparisonRow( + toPreview = { + LoginPage(accountViewModel, true) {} + }, + ) +} + +@Composable +fun LoginPage( + accountStateViewModel: AccountStateViewModel, + isFirstLogin: Boolean, + newAccountKey: String? = null, + onWantsToLogin: () -> Unit, +) { + val loginViewModel: LoginViewModel = viewModel() + loginViewModel.init(accountStateViewModel) + + LaunchedEffect(Unit) { + loginViewModel.load(isFirstLogin, newAccountKey) + } + + LoginPage(loginViewModel, onWantsToLogin) +} + +@Composable +fun LoginPage( + loginViewModel: LoginViewModel, + onWantsToLogin: () -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + Column( + modifier = + Modifier + .fillMaxSize() + .imePadding() + .verticalScroll(rememberScrollState()) + .padding(Size20dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + imageVector = CustomHashTagIcons.Amethyst, + contentDescription = stringRes(R.string.app_logo), + modifier = Modifier.size(150.dp), + contentScale = ContentScale.Inside, + ) + + Spacer(modifier = Modifier.height(Size40dp)) + + KeyTextField( + value = loginViewModel.key, + onValueChange = loginViewModel::updateKey, + onLogin = loginViewModel::login, + ) + + loginViewModel.errorManager.error?.let { error -> + when (error) { + is LoginErrorManager.SingleErrorMsg -> + Text( + text = stringRes(error.errorResId), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + is LoginErrorManager.ParamsErrorMsg -> + Text( + text = stringRes(error.errorResId, *error.params), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + else -> {} + } + } + + Spacer(modifier = Modifier.height(10.dp)) + + PasswordField(loginViewModel) + + Spacer(modifier = Modifier.height(10.dp)) + + TorSettingsSetup( + torSettings = loginViewModel.torSettings, + onCheckedChange = loginViewModel::updateTorSettings, + onError = { + scope.launch { + Toast + .makeText( + context, + it, + Toast.LENGTH_LONG, + ).show() + } + }, + ) + + if (loginViewModel.offerTemporaryLogin) { + OfferTemporaryAccount( + checked = loginViewModel.isTemporary, + onCheckedChange = { loginViewModel.isTemporary = it }, + ) + } + + if (loginViewModel.isFirstLogin) { + AcceptTerms( + checked = loginViewModel.acceptedTerms, + onCheckedChange = loginViewModel::updateAcceptedTerms, + ) + + if (loginViewModel.termsAcceptanceIsRequiredError) { + Text( + text = stringRes(R.string.acceptance_of_terms_is_required), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + } + + Spacer(modifier = Modifier.height(Size10dp)) + + Box(modifier = Modifier.padding(Size40dp, 0.dp, Size40dp, 0.dp)) { + LoginButton( + enabled = loginViewModel.acceptedTerms, + processingLogin = loginViewModel.processingLogin, + onClick = loginViewModel::login, + ) + } + + if (isExternalSignerInstalled(context)) { + ExternalSignerButton(loginViewModel) + } + + Spacer(modifier = Modifier.height(Size40dp)) + + Text(text = stringRes(R.string.don_t_have_an_account)) + + Spacer(modifier = Modifier.height(Size20dp)) + + Box(modifier = Modifier.padding(Size40dp, 0.dp, Size40dp, 0.dp)) { + SignUpButton(onWantsToLogin) + } + } + + OpenURIIfNotLoggedIn { key -> + loginViewModel.updateKey(TextFieldValue(key), true) + loginViewModel.updateOfferTemporaryLogin(true) + } +} + +@Composable +fun OfferTemporaryAccount( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = checked, + onCheckedChange = onCheckedChange, + ) + + Text(stringRes(R.string.temporary_account)) + } +} + +@Composable +private fun PasswordField(loginViewModel: LoginViewModel) { + if (loginViewModel.needsPassword) { + val passwordFocusRequester = remember { FocusRequester() } + + PasswordField( + value = loginViewModel.password, + onValueChange = loginViewModel::updatePassword, + passwordFocusRequester = passwordFocusRequester, + onGo = loginViewModel::login, + ) + + LaunchedEffect(Unit) { + delay(300) + passwordFocusRequester.requestFocus() + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun PasswordField( + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + passwordFocusRequester: FocusRequester, + onGo: () -> Unit, +) { + val autofillNodeKey = + AutofillNode( + autofillTypes = listOf(AutofillType.Password), + onFill = { onValueChange(TextFieldValue(it)) }, + ) + + val autofillNodePassword = + AutofillNode( + autofillTypes = listOf(AutofillType.Password), + onFill = { onValueChange(TextFieldValue(it)) }, + ) + + val autofill = LocalAutofill.current + LocalAutofillTree.current += autofillNodeKey + LocalAutofillTree.current += autofillNodePassword + + var showCharsPassword by remember { mutableStateOf(false) } + OutlinedTextField( + modifier = + Modifier + .focusRequester(passwordFocusRequester) + .onGloballyPositioned { coordinates -> + autofillNodePassword.boundingBox = coordinates.boundsInWindow() + }.onFocusChanged { focusState -> + autofill?.run { + if (focusState.isFocused) { + requestAutofillForNode(autofillNodePassword) + } else { + cancelAutofillForNode(autofillNodePassword) + } + } + }, + value = value, + onValueChange = onValueChange, + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go, + ), + placeholder = { + Text( + text = stringRes(R.string.ncryptsec_password), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + trailingIcon = { + Row { + IconButton(onClick = { showCharsPassword = !showCharsPassword }) { + Icon( + imageVector = + if (showCharsPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = + if (showCharsPassword) { + stringRes(R.string.show_password) + } else { + stringRes( + R.string.hide_password, + ) + }, + ) + } + } + }, + visualTransformation = + if (showCharsPassword) VisualTransformation.None else PasswordVisualTransformation(), + keyboardActions = + KeyboardActions( + onGo = { + onGo() + }, + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt new file mode 100644 index 0000000000..eab3f7b756 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt @@ -0,0 +1,171 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedOff.login + +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel +import com.vitorpamplona.amethyst.ui.tor.TorSettings + +class LoginViewModel : ViewModel() { + lateinit var accountStateViewModel: AccountStateViewModel + + val errorManager = LoginErrorManager() + + var key by mutableStateOf(TextFieldValue("")) + var acceptedTerms by mutableStateOf(false) + var termsAcceptanceIsRequiredError by mutableStateOf(false) + + var torSettings by mutableStateOf(TorSettings()) + var offerTemporaryLogin by mutableStateOf(false) + var isTemporary by mutableStateOf(false) + + var processingLogin by mutableStateOf(false) + + var password by mutableStateOf(TextFieldValue("")) + val needsPassword by derivedStateOf { + key.text.startsWith("ncryptsec1") + } + + var isFirstLogin by mutableStateOf(false) + + fun init(accountStateViewModel: AccountStateViewModel) { + this.accountStateViewModel = accountStateViewModel + } + + fun load( + isFirstLogin: Boolean, + newAccountKey: String?, + ) { + clear() + this.isFirstLogin = isFirstLogin + acceptedTerms = !isFirstLogin + if (newAccountKey != null) { + key = TextFieldValue(newAccountKey) + } + } + + fun clear() { + key = TextFieldValue("") + password = TextFieldValue("") + + errorManager.clearErrors() + acceptedTerms = false + processingLogin = false + isTemporary = false + offerTemporaryLogin = false + torSettings = TorSettings() + isFirstLogin = false + } + + fun updateKey( + value: TextFieldValue, + throughQR: Boolean, + ) { + key = value + if (throughQR) { + offerTemporaryLogin = true + isTemporary = true + } + errorManager.clearErrors() + } + + fun updatePassword(newPassword: TextFieldValue) { + password = newPassword + errorManager.clearErrors() + } + + fun updateTorSettings(newTorSettings: TorSettings) { + torSettings = newTorSettings + } + + fun updateAcceptedTerms(newAcceptedTerms: Boolean) { + acceptedTerms = newAcceptedTerms + if (newAcceptedTerms) { + termsAcceptanceIsRequiredError = false + } + + errorManager.clearErrors() + } + + fun updateOfferTemporaryLogin(tempLogin: Boolean) { + offerTemporaryLogin = tempLogin + } + + fun checkCanLogin(): Boolean { + if (!acceptedTerms) { + termsAcceptanceIsRequiredError = true + return false + } + + if (key.text.isBlank()) { + errorManager.error(R.string.key_is_required) + return false + } + + if (needsPassword && password.text.isBlank()) { + errorManager.error(R.string.password_is_required) + return false + } + + return true + } + + fun login() { + if (checkCanLogin()) { + processingLogin = true + accountStateViewModel.login( + key = key.text, + password = password.text, + torSettings = torSettings, + transientAccount = isTemporary, + ) { + processingLogin = false + if (it != null) { + errorManager.error(R.string.invalid_key_with_message, it) + } else { + errorManager.error(R.string.invalid_key) + } + } + } + } + + fun loginWithExternalSigner(packageName: String) { + if (checkCanLogin()) { + processingLogin = true + accountStateViewModel.login( + key = key.text, + torSettings = torSettings, + transientAccount = isTemporary, + loginWithExternalSigner = true, + packageName = packageName, + ) { + processingLogin = false + errorManager.error(R.string.sign_request_rejected_description) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginWithAmberButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginWithAmberButton.kt new file mode 100644 index 0000000000..d293ac7606 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginWithAmberButton.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedOff.login + +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.Size40dp +import com.vitorpamplona.amethyst.ui.theme.Size50dp + +@Composable +fun LoginWithAmberButton( + enabled: Boolean, + onClick: () -> Unit, +) { + Button( + enabled = enabled, + onClick = onClick, + shape = RoundedCornerShape(Size35dp), + modifier = Modifier.height(Size50dp), + ) { + Text( + text = stringRes(R.string.login_with_external_signer), + modifier = Modifier.padding(horizontal = Size40dp), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/OpenURIIfNotLoggedIn.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/OpenURIIfNotLoggedIn.kt new file mode 100644 index 0000000000..856306819c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/OpenURIIfNotLoggedIn.kt @@ -0,0 +1,116 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedOff.login + +import android.content.Intent +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.core.util.Consumer +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.components.getActivity +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun OpenURIIfNotLoggedIn(onNewNIP19: suspend (String) -> Unit) { + val context = LocalContext.current + val activity = context.getActivity() + val scope = rememberCoroutineScope() + + var currentIntentNextPage by remember { + val uri = + activity.intent + ?.data + ?.toString() + ?.ifBlank { null } + + activity.intent.data = null + + mutableStateOf(uri) + } + + currentIntentNextPage?.let { intentNextPage -> + var nip19 by remember { + mutableStateOf( + Nip19Parser.tryParseAndClean(currentIntentNextPage), + ) + } + + LaunchedEffect(intentNextPage) { + if (nip19 != null) { + nip19?.let { + scope.launch { + onNewNIP19(it) + } + nip19 = null + } + } else { + scope.launch { + Toast + .makeText( + context, + stringRes(context, R.string.invalid_nip19_uri_description, intentNextPage), + Toast.LENGTH_SHORT, + ).show() + } + } + + currentIntentNextPage = null + } + } + + DisposableEffect(activity) { + val consumer = + Consumer { intent -> + val uri = intent.data?.toString() + if (!uri.isNullOrBlank()) { + val newNip19 = Nip19Parser.tryParseAndClean(uri) + if (newNip19 != null) { + scope.launch { + onNewNIP19(newNip19) + } + } else { + scope.launch { + delay(1000) + Toast + .makeText( + context, + stringRes(context, R.string.invalid_nip19_uri_description, uri), + Toast.LENGTH_SHORT, + ).show() + } + } + } + } + activity.addOnNewIntentListener(consumer) + onDispose { activity.removeOnNewIntentListener(consumer) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/SignUpButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/SignUpButton.kt new file mode 100644 index 0000000000..140d5865e5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/SignUpButton.kt @@ -0,0 +1,48 @@ +/** + * 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.ui.screen.loggedOff.login + +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.Size40dp + +@Composable +fun SignUpButton(onClick: () -> Unit) { + OutlinedButton( + onClick = onClick, + shape = RoundedCornerShape(Size35dp), + modifier = Modifier.height(50.dp), + ) { + Text( + text = stringRes(R.string.sign_up), + modifier = Modifier.padding(horizontal = Size40dp), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/LoginButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/LoginButton.kt new file mode 100644 index 0000000000..7991bf9ba5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/LoginButton.kt @@ -0,0 +1,48 @@ +/** + * 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.ui.screen.loggedOff.signup + +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.Size40dp + +@Composable +fun LoginButton(onWantsToLogin: () -> Unit) { + OutlinedButton( + onClick = onWantsToLogin, + shape = RoundedCornerShape(Size35dp), + modifier = Modifier.height(50.dp), + ) { + Text( + text = stringRes(R.string.login), + modifier = Modifier.padding(horizontal = Size40dp), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/products/AddClassifiedsButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpButton.kt similarity index 54% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/products/AddClassifiedsButton.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpButton.kt index 72f7ad965a..64076886ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/products/AddClassifiedsButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpButton.kt @@ -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,43 +18,35 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.creators.products +package com.vitorpamplona.amethyst.ui.screen.loggedOff.signup -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Sell -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.Size40dp @Composable -fun AddClassifiedsButton( - isActive: Boolean, +fun SignUpButton( + enabled: Boolean, onClick: () -> Unit, ) { - IconButton( - onClick = { onClick() }, + Button( + enabled = enabled, + onClick = onClick, + shape = RoundedCornerShape(Size35dp), + modifier = Modifier.height(50.dp), ) { - if (!isActive) { - Icon( - imageVector = Icons.Default.Sell, - contentDescription = stringRes(R.string.classifieds), - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onBackground, - ) - } else { - Icon( - imageVector = Icons.Default.Sell, - contentDescription = stringRes(id = R.string.cancel_classifieds), - modifier = Modifier.size(20.dp), - tint = BitcoinOrange, - ) - } + Text( + text = stringRes(R.string.create_account), + modifier = Modifier.padding(horizontal = Size40dp), + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/SignUpScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt similarity index 63% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/SignUpScreen.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt index 77f73f43d2..cbb8f530f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/SignUpScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt @@ -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,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedOff +package com.vitorpamplona.amethyst.ui.screen.loggedOff.signup import android.widget.Toast import androidx.compose.foundation.Image @@ -32,28 +32,20 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -61,19 +53,20 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.hashtags.Amethyst import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedOff.AcceptTerms +import com.vitorpamplona.amethyst.ui.screen.loggedOff.TorSettingsSetup +import com.vitorpamplona.amethyst.ui.screen.loggedOff.login.LoginErrorManager import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size20dp -import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size40dp import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.amethyst.ui.tor.TorSettings import kotlinx.coroutines.launch @Preview(device = "spec:width=2160px,height=2340px,dpi=440") @Composable -fun SignUpPage() { +fun SignUpPagePreview() { val accountViewModel: AccountStateViewModel = viewModel() ThemeComparisonRow( @@ -88,13 +81,18 @@ fun SignUpPage( accountStateViewModel: AccountStateViewModel, onWantsToLogin: () -> Unit, ) { - val displayName = remember { mutableStateOf(TextFieldValue("")) } - var errorMessage by remember { mutableStateOf("") } - val acceptedTerms = remember { mutableStateOf(false) } - var termsAcceptanceIsRequired by remember { mutableStateOf("") } + val signUpViewModel: SignUpViewModel = viewModel() + signUpViewModel.init(accountStateViewModel) + SignUpPage(signUpViewModel, onWantsToLogin) +} + +@Composable +fun SignUpPage( + signUpViewModel: SignUpViewModel, + onWantsToLogin: () -> Unit, +) { val context = LocalContext.current - val torSettings = remember { mutableStateOf(TorSettings()) } val scope = rememberCoroutineScope() Column( @@ -125,8 +123,8 @@ fun SignUpPage( Spacer(modifier = Modifier.height(Size20dp)) OutlinedTextField( - value = displayName.value, - onValueChange = { displayName.value = it }, + value = signUpViewModel.displayName, + onValueChange = signUpViewModel::updateDisplayName, keyboardOptions = KeyboardOptions( autoCorrectEnabled = false, @@ -142,49 +140,47 @@ fun SignUpPage( keyboardActions = KeyboardActions( onGo = { - if (!acceptedTerms.value) { - termsAcceptanceIsRequired = - stringRes(context, R.string.acceptance_of_terms_is_required) - } - - if (displayName.value.text.isBlank()) { - errorMessage = stringRes(context, R.string.name_is_required) - } - - if (acceptedTerms.value && displayName.value.text.isNotBlank()) { - accountStateViewModel.newKey(torSettings.value, displayName.value.text) - } + signUpViewModel.signup() }, ), ) - if (errorMessage.isNotBlank()) { - Text( - text = errorMessage, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - ) + + signUpViewModel.errorManager.error?.let { error -> + when (error) { + is LoginErrorManager.SingleErrorMsg -> + Text( + text = stringRes(error.errorResId), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + is LoginErrorManager.ParamsErrorMsg -> + Text( + text = stringRes(error.errorResId, *error.params), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + else -> {} + } } Spacer(modifier = Modifier.height(10.dp)) AcceptTerms( - checked = acceptedTerms.value, - onCheckedChange = { acceptedTerms.value = it }, + checked = signUpViewModel.acceptedTerms, + onCheckedChange = signUpViewModel::updateAcceptedTerms, ) - if (termsAcceptanceIsRequired.isNotBlank()) { + if (signUpViewModel.termsAcceptanceIsRequiredError) { Text( - text = termsAcceptanceIsRequired, + text = stringRes(R.string.acceptance_of_terms_is_required), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, ) } TorSettingsSetup( - torSettings = torSettings.value, - onCheckedChange = { - torSettings.value = it - }, + torSettings = signUpViewModel.torSettings, + onCheckedChange = signUpViewModel::updateTorSettings, onError = { scope.launch { Toast @@ -201,20 +197,8 @@ fun SignUpPage( Box(modifier = Modifier.padding(Size40dp, 0.dp, Size40dp, 0.dp)) { SignUpButton( - enabled = acceptedTerms.value, - onClick = { - if (!acceptedTerms.value) { - termsAcceptanceIsRequired = stringRes(context, R.string.acceptance_of_terms_is_required) - } - - if (displayName.value.text.isBlank()) { - errorMessage = stringRes(context, R.string.name_is_required) - } - - if (acceptedTerms.value && displayName.value.text.isNotBlank()) { - accountStateViewModel.newKey(torSettings.value, displayName.value.text) - } - }, + enabled = signUpViewModel.acceptedTerms, + onClick = signUpViewModel::signup, ) } @@ -229,35 +213,3 @@ fun SignUpPage( } } } - -@Composable -fun LoginButton(onWantsToLogin: () -> Unit) { - OutlinedButton( - onClick = onWantsToLogin, - shape = RoundedCornerShape(Size35dp), - modifier = Modifier.height(50.dp), - ) { - Text( - text = stringRes(R.string.login), - modifier = Modifier.padding(horizontal = Size40dp), - ) - } -} - -@Composable -fun SignUpButton( - enabled: Boolean, - onClick: () -> Unit, -) { - Button( - enabled = enabled, - onClick = onClick, - shape = RoundedCornerShape(Size35dp), - modifier = Modifier.height(50.dp), - ) { - Text( - text = stringRes(R.string.create_account), - modifier = Modifier.padding(horizontal = Size40dp), - ) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpViewModel.kt new file mode 100644 index 0000000000..39f8500a35 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpViewModel.kt @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedOff.signup + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedOff.login.LoginErrorManager +import com.vitorpamplona.amethyst.ui.tor.TorSettings + +class SignUpViewModel : ViewModel() { + lateinit var accountStateViewModel: AccountStateViewModel + + val errorManager = LoginErrorManager() + + var displayName by mutableStateOf(TextFieldValue("")) + + var acceptedTerms by mutableStateOf(false) + var termsAcceptanceIsRequiredError by mutableStateOf(false) + + var torSettings by mutableStateOf(TorSettings()) + + fun init(accountStateViewModel: AccountStateViewModel) { + this.accountStateViewModel = accountStateViewModel + } + + fun updateDisplayName(value: TextFieldValue) { + displayName = value + errorManager.clearErrors() + } + + fun updateTorSettings(newTorSettings: TorSettings) { + torSettings = newTorSettings + } + + fun updateAcceptedTerms(newAcceptedTerms: Boolean) { + acceptedTerms = newAcceptedTerms + if (newAcceptedTerms) { + termsAcceptanceIsRequiredError = false + } + + errorManager.clearErrors() + } + + fun checkCanSignup(): Boolean { + if (!acceptedTerms) { + termsAcceptanceIsRequiredError = true + return false + } + + if (displayName.text.isBlank()) { + errorManager.error(R.string.name_is_required) + return false + } + + return true + } + + fun signup() { + if (checkCanSignup()) { + accountStateViewModel.newKey(torSettings, displayName.text) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt index 2f1f73135c..f9acf35efc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt @@ -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 @@ -52,13 +52,20 @@ val Nip05EmailColorLight = Color(0xFFa770f3) val DarkerGreen = Color.Green.copy(alpha = 0.32f) -val WarningColor = Color(0xFFC62828) +val LightRedColor = Color(0xFFC62828) +val LighterRedColor = Color(0xFFFF0E0E) val RelayIconFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0.5f) }) val LightWarningColor = Color(0xFFffcc00) val DarkWarningColor = Color(0xFFF8DE22) +val LightRedColorOnSecondSurface = Color(0xFFC62828) +val DarkRedColorOnSecondSurface = Color(0xFFF34747) + +val LightWarningColorOnSecondSurface = Color(0xFFC09B14) +val DarkWarningColorOnSecondSurface = Color(0xFFE1C419) + val LightAllGoodColor = Color(0xFF339900) val DarkAllGoodColor = Color(0xFF99cc33) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Preview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Preview.kt index c43681fb47..71a5597f91 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Preview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Preview.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index b7c2d716c7..27a25348e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -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,9 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn @@ -34,6 +36,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.CutCornerShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Shapes @@ -82,9 +85,9 @@ val StdVertSpacer = Modifier.height(5.dp) val DoubleHorzSpacer = Modifier.width(10.dp) val DoubleVertSpacer = Modifier.height(10.dp) -val HalfDoubleVertSpacer = Modifier.height(7.dp) +val Height100Modifier = Modifier.height(100.dp) -val TopBarSize = 50.dp - 64.dp +val HalfDoubleVertSpacer = Modifier.height(7.dp) val Size0dp = 0.dp val Size5dp = 5.dp @@ -177,7 +180,7 @@ val VideoReactionColumnPadding = Modifier.padding(bottom = 75.dp) val DividerThickness = 0.25.dp val ReactionRowHeight = Modifier.padding(vertical = 7.dp).height(24.dp) -val ReactionRowHeightWithPadding = Modifier.padding(vertical = 7.dp).height(24.dp).padding(horizontal = 10.dp) +val ReactionRowHeightWithPadding = Modifier.padding(vertical = 6.dp).height(24.dp).padding(horizontal = 10.dp) val ReactionRowHeightChat = Modifier.height(20.dp) val ReactionRowHeightChatMaxWidth = Modifier.height(25.dp).fillMaxWidth() val UserNameRowHeight = Modifier.fillMaxWidth() @@ -217,6 +220,7 @@ val EditFieldModifier = val EditFieldTrailingIconModifier = Modifier.padding(start = 5.dp, end = 0.dp) val ZeroPadding = PaddingValues(0.dp) +val HalfFeedPadding = PaddingValues(5.dp) val FeedPadding = PaddingValues(top = 10.dp, bottom = 10.dp) val ButtonPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp) @@ -283,10 +287,8 @@ val inlinePlaceholder = placeholderVerticalAlign = PlaceholderVerticalAlign.Center, ) -val incognitoIconModifier = - Modifier - .padding(top = 1.dp) - .size(14.dp) +val IncognitoIconModifier = Modifier.padding(top = 1.dp).size(14.dp) +val IncognitoIconButtonModifier = Modifier.padding(top = 2.dp).size(20.dp) val hashVerifierMark = Modifier.width(40.dp).height(40.dp).padding(10.dp) @@ -316,3 +318,34 @@ val PostKeyboard = autoCorrectEnabled = true, capitalization = KeyboardCapitalization.Sentences, ) + +val SettingsCategoryFirstModifier = Modifier.padding(bottom = 8.dp) +val SettingsCategorySpacingModifier = Modifier.padding(top = 24.dp, bottom = 8.dp) + +val SquaredQuoteBorderModifier = Modifier.aspectRatio(1f).clip(shape = QuoteBorder) +val FillWidthQuoteBorderModifier = Modifier.fillMaxWidth().clip(shape = QuoteBorder) + +val MediumRelayIconModifier = + Modifier + .size(Size35dp) + .clip(shape = CircleShape) + +val LargeRelayIconModifier = + Modifier + .size(Size55dp) + .clip(shape = CircleShape) + +val FollowSetImageModifier = + Modifier + .fillMaxWidth() + .clip(QuoteBorder) + .aspectRatio(ratio = 21f / 9f) + +val SimpleImage75Modifier = Modifier.size(Size75dp).clip(QuoteBorder) +val SimpleImage35Modifier = Modifier.size(Size34dp).clip(shape = CircleShape) + +val SimpleImageBorder = Modifier.fillMaxSize().clip(QuoteBorder) + +val SimpleHeaderImage = Modifier.fillMaxWidth().heightIn(max = 200.dp) + +val BadgePictureModifier = Modifier.size(35.dp).clip(shape = CutCornerShape(20)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index 70edb0362e..2258053c4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -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 @@ -65,9 +65,9 @@ private val DarkColorPalette = primary = Purple200, secondary = Teal200, tertiary = Teal200, - background = Color.Black, // full black theme - surface = Color.Black, // full black theme - surfaceDim = Color.Black, // full black theme + background = Color.Black, + surface = Color.Black, + surfaceDim = Color.Black, surfaceVariant = Color(red = 29, green = 26, blue = 34), ) @@ -167,35 +167,21 @@ val DarkReplyBorderModifier = val LightReplyBorderModifier = Modifier - .padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp) + .padding(top = 5.dp) .fillMaxWidth() .clip(shape = QuoteBorder) .border(1.dp, LightSubtleBorder, QuoteBorder) -val DarkVideoBorderModifier = - Modifier - .padding(top = 5.dp) - .fillMaxWidth() - .clip(shape = RectangleShape) - .border(1.dp, DarkSubtleBorder, RectangleShape) - -val LightVideoBorderModifier = - Modifier - .padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp) - .fillMaxWidth() - .clip(shape = RectangleShape) - .border(1.dp, LightSubtleBorder, RectangleShape) - val DarkInnerPostBorderModifier = Modifier - .padding(vertical = 5.dp) + .padding(vertical = 4.dp) .fillMaxWidth() .clip(shape = QuoteBorder) .border(1.dp, DarkSubtleBorder, QuoteBorder) val LightInnerPostBorderModifier = Modifier - .padding(vertical = 5.dp) + .padding(vertical = 4.dp) .fillMaxWidth() .clip(shape = QuoteBorder) .border(1.dp, LightSubtleBorder, QuoteBorder) @@ -262,16 +248,6 @@ val DarkRelayIconModifier = .size(Size13dp) .clip(shape = CircleShape) -val LightLargeRelayIconModifier = - Modifier - .size(Size55dp) - .clip(shape = CircleShape) - -val DarkLargeRelayIconModifier = - Modifier - .size(Size55dp) - .clip(shape = CircleShape) - val darkLargeProfilePictureModifier = Modifier .width(120.dp) @@ -415,9 +391,15 @@ val ColorScheme.overPictureBackground: Color val ColorScheme.bitcoinColor: Color get() = if (isLight) BitcoinLight else BitcoinDark +val ColorScheme.redColorOnSecondSurface: Color + get() = if (isLight) LightRedColorOnSecondSurface else DarkRedColorOnSecondSurface + val ColorScheme.warningColor: Color get() = if (isLight) LightWarningColor else DarkWarningColor +val ColorScheme.warningColorOnSecondSurface: Color + get() = if (isLight) LightWarningColorOnSecondSurface else DarkWarningColorOnSecondSurface + val ColorScheme.allGoodColor: Color get() = if (isLight) LightAllGoodColor else DarkAllGoodColor @@ -427,39 +409,47 @@ val ColorScheme.fundraiserProgressColor: Color val ColorScheme.markdownStyle: RichTextStyle get() = if (isLight) MarkDownStyleOnLight else MarkDownStyleOnDark +@Suppress("ModifierFactoryExtensionFunction") val ColorScheme.imageModifier: Modifier get() = if (isLight) LightImageModifier else DarkImageModifier +@Suppress("ModifierFactoryExtensionFunction") val ColorScheme.videoGalleryModifier: Modifier get() = if (isLight) LightVideoModifier else DarkVideoModifier +@Suppress("ModifierFactoryExtensionFunction") val ColorScheme.profile35dpModifier: Modifier get() = if (isLight) LightProfile35dpModifier else DarkProfile35dpModifier +@Suppress("ModifierFactoryExtensionFunction") val ColorScheme.replyModifier: Modifier get() = if (isLight) LightReplyBorderModifier else DarkReplyBorderModifier +@Suppress("ModifierFactoryExtensionFunction") val ColorScheme.innerPostModifier: Modifier get() = if (isLight) LightInnerPostBorderModifier else DarkInnerPostBorderModifier +@Suppress("ModifierFactoryExtensionFunction") val ColorScheme.maxWidthWithBackground: Modifier get() = if (isLight) LightMaxWidthWithBackground else DarkMaxWidthWithBackground +@Suppress("ModifierFactoryExtensionFunction") val ColorScheme.channelNotePictureModifier: Modifier get() = if (isLight) LightChannelNotePictureModifier else DarkChannelNotePictureModifier +@Suppress("ModifierFactoryExtensionFunction") val ColorScheme.userProfileBorderModifier: Modifier get() = if (isLight) LightProfilePictureBorder else DarkProfilePictureBorder +@Suppress("ModifierFactoryExtensionFunction") val ColorScheme.relayIconModifier: Modifier get() = if (isLight) LightRelayIconModifier else DarkRelayIconModifier -val ColorScheme.largeRelayIconModifier: Modifier - get() = if (isLight) LightLargeRelayIconModifier else DarkLargeRelayIconModifier - +@Suppress("ModifierFactoryExtensionFunction") val ColorScheme.selectedReactionBoxModifier: Modifier get() = if (isLight) LightSelectedReactionBoxModifier else DarkSelectedReactionBoxModifier +@Suppress("ModifierFactoryExtensionFunction") val ColorScheme.largeProfilePictureModifier: Modifier get() = if (isLight) lightLargeProfilePictureModifier else darkLargeProfilePictureModifier @@ -530,7 +520,9 @@ fun AmethystTheme( insets.isAppearanceLightNavigationBars = !darkTheme insets.isAppearanceLightStatusBars = !darkTheme + @Suppress("DEPRECATION") window.statusBarColor = colors.transparentBackground.toArgb() + @Suppress("DEPRECATION") window.navigationBarColor = colors.transparentBackground.toArgb() view.setBackgroundColor(colors.background.toArgb()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt index 99240f0c0d..7c347bbe44 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorDialogViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorDialogViewModel.kt index 0569d4dfca..ac262443d5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorDialogViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorDialogViewModel.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt index 31773c6cb5..9b0169c6b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt @@ -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,6 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import org.torproject.jni.TorService /** * There should be only one instance of the Tor binding per app. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt index 024b1b0852..7d760d3ff1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorServiceStatus.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorServiceStatus.kt index cf39123194..cb220ce2c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorServiceStatus.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorServiceStatus.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettings.kt index 85cbbeed74..11edf8e835 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettings.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsDialog.kt index 647d8810a7..fc370db073 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsDialog.kt @@ -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,22 +23,19 @@ package com.vitorpamplona.amethyst.ui.tor import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface +import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType @@ -48,8 +45,8 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton +import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge +import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow import com.vitorpamplona.amethyst.ui.stringRes @@ -74,14 +71,13 @@ fun ConnectTorDialog( decorFitsSystemWindows = false, ), ) { - Surface { - TorDialogContents( - torSettings, - onClose, - onPost, - onError, - ) - } + SetDialogToEdgeToEdge() + TorDialogContents( + torSettings, + onClose, + onPost, + onError, + ) } } @@ -121,24 +117,12 @@ fun TorDialogContents( onPost: (torSettings: TorSettings) -> Unit, onError: (String) -> Unit, ) { - Column( - modifier = - Modifier - .fillMaxSize() - .verticalScroll( - rememberScrollState(), - ).padding(10.dp), - ) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - CloseButton(onPress = { onClose() }) - + Scaffold( + topBar = { val toastMessage = stringRes(R.string.invalid_port_number) - - SaveButton( + SavingTopBar( + titleRes = R.string.privacy_options, + onCancel = onClose, onPost = { try { onPost(dialogViewModel.save()) @@ -147,144 +131,157 @@ fun TorDialogContents( onError(toastMessage) } }, - isActive = true, ) + }, + ) { + Column( + Modifier + .padding(it) + .fillMaxSize() + .verticalScroll( + rememberScrollState(), + ).padding(horizontal = 10.dp), + ) { + PrivacySettingsBody(dialogViewModel) + } + } +} + +@Composable +fun PrivacySettingsBody(dialogViewModel: TorDialogViewModel) { + Column( + modifier = Modifier.padding(horizontal = 5.dp), + verticalArrangement = Arrangement.spacedBy(Size10dp), + ) { + SettingsRow( + R.string.use_internal_tor, + R.string.use_internal_tor_explainer, + persistentListOf( + TitleExplainer(stringRes(TorType.OFF.resourceId)), + TitleExplainer(stringRes(TorType.INTERNAL.resourceId)), + TitleExplainer(stringRes(TorType.EXTERNAL.resourceId)), + ), + dialogViewModel.torType.value.screenCode, + ) { + dialogViewModel.torType.value = parseTorType(it) } + AnimatedVisibility( + visible = dialogViewModel.torType.value == TorType.EXTERNAL, + ) { + SettingsRow( + R.string.orbot_socks_port, + R.string.connect_through_your_orbot_setup_short, + ) { + OutlinedTextField( + value = dialogViewModel.socksPortStr.value, + onValueChange = { dialogViewModel.socksPortStr.value = it }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Number, + ), + placeholder = { + Text( + text = "9050", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + ) + } + } + } + + AnimatedVisibility( + visible = dialogViewModel.torType.value != TorType.OFF, + ) { Column( modifier = Modifier.padding(horizontal = 5.dp), verticalArrangement = Arrangement.spacedBy(Size10dp), ) { SettingsRow( - R.string.use_internal_tor, - R.string.use_internal_tor_explainer, + R.string.tor_preset, + R.string.tor_preset_explainer, persistentListOf( - TitleExplainer(stringRes(TorType.OFF.resourceId)), - TitleExplainer(stringRes(TorType.INTERNAL.resourceId)), - TitleExplainer(stringRes(TorType.EXTERNAL.resourceId)), + TitleExplainer(stringRes(TorPresetType.ONLY_WHEN_NEEDED.resourceId), stringRes(TorPresetType.ONLY_WHEN_NEEDED.explainerId)), + TitleExplainer(stringRes(TorPresetType.DEFAULT.resourceId), stringRes(TorPresetType.DEFAULT.explainerId)), + TitleExplainer(stringRes(TorPresetType.SMALL_PAYLOADS.resourceId), stringRes(TorPresetType.SMALL_PAYLOADS.explainerId)), + TitleExplainer(stringRes(TorPresetType.FULL_PRIVACY.resourceId), stringRes(TorPresetType.FULL_PRIVACY.explainerId)), + TitleExplainer(stringRes(TorPresetType.CUSTOM.resourceId), stringRes(TorPresetType.CUSTOM.explainerId)), ), - dialogViewModel.torType.value.screenCode, + dialogViewModel.preset.value.screenCode, ) { - dialogViewModel.torType.value = parseTorType(it) + dialogViewModel.setPreset(parseTorPresetType(it)) } - AnimatedVisibility( - visible = dialogViewModel.torType.value == TorType.EXTERNAL, - ) { - SettingsRow( - R.string.orbot_socks_port, - R.string.connect_through_your_orbot_setup_short, - ) { - OutlinedTextField( - value = dialogViewModel.socksPortStr.value, - onValueChange = { dialogViewModel.socksPortStr.value = it }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.None, - keyboardType = KeyboardType.Number, - ), - placeholder = { - Text( - text = "9050", - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - ) - } - } - } + SwitchSettingsRow( + R.string.tor_use_onion_address, + R.string.tor_use_onion_address_explainer, + dialogViewModel.onionRelaysViaTor, + ) - AnimatedVisibility( - visible = dialogViewModel.torType.value != TorType.OFF, - ) { - Column( - modifier = Modifier.padding(horizontal = 5.dp), - verticalArrangement = Arrangement.spacedBy(Size10dp), - ) { - SettingsRow( - R.string.tor_preset, - R.string.tor_preset_explainer, - persistentListOf( - TitleExplainer(stringRes(TorPresetType.ONLY_WHEN_NEEDED.resourceId), stringRes(TorPresetType.ONLY_WHEN_NEEDED.explainerId)), - TitleExplainer(stringRes(TorPresetType.DEFAULT.resourceId), stringRes(TorPresetType.DEFAULT.explainerId)), - TitleExplainer(stringRes(TorPresetType.SMALL_PAYLOADS.resourceId), stringRes(TorPresetType.SMALL_PAYLOADS.explainerId)), - TitleExplainer(stringRes(TorPresetType.FULL_PRIVACY.resourceId), stringRes(TorPresetType.FULL_PRIVACY.explainerId)), - TitleExplainer(stringRes(TorPresetType.CUSTOM.resourceId), stringRes(TorPresetType.CUSTOM.explainerId)), - ), - dialogViewModel.preset.value.screenCode, - ) { - dialogViewModel.setPreset(parseTorPresetType(it)) - } + SwitchSettingsRow( + R.string.tor_use_dm_relays, + R.string.tor_use_dm_relays_explainer, + dialogViewModel.dmRelaysViaTor, + ) - SwitchSettingsRow( - R.string.tor_use_onion_address, - R.string.tor_use_onion_address_explainer, - dialogViewModel.onionRelaysViaTor, - ) + SwitchSettingsRow( + R.string.tor_use_new_relays, + R.string.tor_use_new_relays_explainer, + dialogViewModel.newRelaysViaTor, + ) - SwitchSettingsRow( - R.string.tor_use_dm_relays, - R.string.tor_use_dm_relays_explainer, - dialogViewModel.dmRelaysViaTor, - ) + SwitchSettingsRow( + R.string.tor_use_trusted_relays, + R.string.tor_use_trusted_relays_explainer, + dialogViewModel.trustedRelaysViaTor, + ) - SwitchSettingsRow( - R.string.tor_use_new_relays, - R.string.tor_use_new_relays_explainer, - dialogViewModel.newRelaysViaTor, - ) + SwitchSettingsRow( + R.string.tor_use_money_operations, + R.string.tor_use_money_operations_explainer, + dialogViewModel.moneyOperationsViaTor, + ) - SwitchSettingsRow( - R.string.tor_use_trusted_relays, - R.string.tor_use_trusted_relays_explainer, - dialogViewModel.trustedRelaysViaTor, - ) + /** + * Too hard to separate Coil into regular images and profile pics + SwitchSettingsRow( + R.string.tor_use_profile_pictures, + R.string.tor_use_profile_pictures_explainer, + dialogViewModel.profilePicsViaTor, + ) + */ - SwitchSettingsRow( - R.string.tor_use_money_operations, - R.string.tor_use_money_operations_explainer, - dialogViewModel.moneyOperationsViaTor, - ) + SwitchSettingsRow( + R.string.tor_use_nip05_verification, + R.string.tor_use_nip05_verification_explainer, + dialogViewModel.nip05VerificationsViaTor, + ) - /** - * Too hard to separate Coil into regular images and profile pics - SwitchSettingsRow( - R.string.tor_use_profile_pictures, - R.string.tor_use_profile_pictures_explainer, - dialogViewModel.profilePicsViaTor, - ) - */ + SwitchSettingsRow( + R.string.tor_use_url_previews, + R.string.tor_use_url_previews_explainer, + dialogViewModel.urlPreviewsViaTor, + ) - SwitchSettingsRow( - R.string.tor_use_nip05_verification, - R.string.tor_use_nip05_verification_explainer, - dialogViewModel.nip05VerificationsViaTor, - ) + SwitchSettingsRow( + R.string.tor_use_images, + R.string.tor_use_images_explainer, + dialogViewModel.imagesViaTor, + ) - SwitchSettingsRow( - R.string.tor_use_url_previews, - R.string.tor_use_url_previews_explainer, - dialogViewModel.urlPreviewsViaTor, - ) + SwitchSettingsRow( + R.string.tor_use_videos, + R.string.tor_use_videos_explainer, + dialogViewModel.videosViaTor, + ) - SwitchSettingsRow( - R.string.tor_use_images, - R.string.tor_use_images_explainer, - dialogViewModel.imagesViaTor, - ) - - SwitchSettingsRow( - R.string.tor_use_videos, - R.string.tor_use_videos_explainer, - dialogViewModel.videosViaTor, - ) - - SwitchSettingsRow( - R.string.tor_use_nip96_uploads, - R.string.tor_use_nip96_uploads_explainer, - dialogViewModel.nip96UploadsViaTor, - ) - } + SwitchSettingsRow( + R.string.tor_use_nip96_uploads, + R.string.tor_use_nip96_uploads_explainer, + dialogViewModel.nip96UploadsViaTor, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsFlow.kt index 9e4da606c8..bf57fb3d12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsFlow.kt @@ -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 diff --git a/amethyst/src/main/res/drawable-anydpi/ic_add_photo.xml b/amethyst/src/main/res/drawable-anydpi/ic_add_photo.xml deleted file mode 100644 index 4ebcf7e039..0000000000 --- a/amethyst/src/main/res/drawable-anydpi/ic_add_photo.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/amethyst/src/main/res/drawable-anydpi/ic_close.xml b/amethyst/src/main/res/drawable-anydpi/ic_close.xml deleted file mode 100644 index 844b6b62ef..0000000000 --- a/amethyst/src/main/res/drawable-anydpi/ic_close.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/amethyst/src/main/res/drawable-anydpi/ic_key.xml b/amethyst/src/main/res/drawable-anydpi/ic_key.xml deleted file mode 100644 index 0db74fa0d5..0000000000 --- a/amethyst/src/main/res/drawable-anydpi/ic_key.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/amethyst/src/main/res/drawable-anydpi/ic_logout.xml b/amethyst/src/main/res/drawable-anydpi/ic_logout.xml deleted file mode 100644 index 6555606de9..0000000000 --- a/amethyst/src/main/res/drawable-anydpi/ic_logout.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/amethyst/src/main/res/drawable-anydpi/ic_security.xml b/amethyst/src/main/res/drawable-anydpi/ic_security.xml deleted file mode 100644 index 717b89c055..0000000000 --- a/amethyst/src/main/res/drawable-anydpi/ic_security.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/amethyst/src/main/res/drawable-hdpi/ic_bookmarks.png b/amethyst/src/main/res/drawable-hdpi/ic_bookmarks.png deleted file mode 100644 index 8a7cf8beaa..0000000000 Binary files a/amethyst/src/main/res/drawable-hdpi/ic_bookmarks.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-hdpi/ic_globe.png b/amethyst/src/main/res/drawable-hdpi/ic_globe.png deleted file mode 100644 index 71e7d944d0..0000000000 Binary files a/amethyst/src/main/res/drawable-hdpi/ic_globe.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-hdpi/ic_key.png b/amethyst/src/main/res/drawable-hdpi/ic_key.png deleted file mode 100644 index 81ef03db80..0000000000 Binary files a/amethyst/src/main/res/drawable-hdpi/ic_key.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-hdpi/ic_logout.png b/amethyst/src/main/res/drawable-hdpi/ic_logout.png deleted file mode 100644 index a74a0f2a14..0000000000 Binary files a/amethyst/src/main/res/drawable-hdpi/ic_logout.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-hdpi/ic_profile.png b/amethyst/src/main/res/drawable-hdpi/ic_profile.png deleted file mode 100644 index da70047c2e..0000000000 Binary files a/amethyst/src/main/res/drawable-hdpi/ic_profile.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-hdpi/ic_security.png b/amethyst/src/main/res/drawable-hdpi/ic_security.png deleted file mode 100644 index 5d563a7869..0000000000 Binary files a/amethyst/src/main/res/drawable-hdpi/ic_security.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-hdpi/ic_theme.png b/amethyst/src/main/res/drawable-hdpi/ic_theme.png deleted file mode 100644 index b2b5876a4e..0000000000 Binary files a/amethyst/src/main/res/drawable-hdpi/ic_theme.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-hdpi/ic_topics.png b/amethyst/src/main/res/drawable-hdpi/ic_topics.png deleted file mode 100644 index 1b0973811b..0000000000 Binary files a/amethyst/src/main/res/drawable-hdpi/ic_topics.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-hdpi/ic_trends.png b/amethyst/src/main/res/drawable-hdpi/ic_trends.png deleted file mode 100644 index 7fa5184a3a..0000000000 Binary files a/amethyst/src/main/res/drawable-hdpi/ic_trends.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-hdpi/ic_verified.png b/amethyst/src/main/res/drawable-hdpi/ic_verified.png deleted file mode 100644 index 85a365e0b7..0000000000 Binary files a/amethyst/src/main/res/drawable-hdpi/ic_verified.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-hdpi/ic_verified_transparent.png b/amethyst/src/main/res/drawable-hdpi/ic_verified_transparent.png deleted file mode 100644 index 994fb02f3c..0000000000 Binary files a/amethyst/src/main/res/drawable-hdpi/ic_verified_transparent.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-mdpi/ic_bookmarks.png b/amethyst/src/main/res/drawable-mdpi/ic_bookmarks.png deleted file mode 100644 index ecb040b7a7..0000000000 Binary files a/amethyst/src/main/res/drawable-mdpi/ic_bookmarks.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-mdpi/ic_globe.png b/amethyst/src/main/res/drawable-mdpi/ic_globe.png deleted file mode 100644 index d442d9b3d4..0000000000 Binary files a/amethyst/src/main/res/drawable-mdpi/ic_globe.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-mdpi/ic_key.png b/amethyst/src/main/res/drawable-mdpi/ic_key.png deleted file mode 100644 index 67341e7e15..0000000000 Binary files a/amethyst/src/main/res/drawable-mdpi/ic_key.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-mdpi/ic_logout.png b/amethyst/src/main/res/drawable-mdpi/ic_logout.png deleted file mode 100644 index dc2dabde5e..0000000000 Binary files a/amethyst/src/main/res/drawable-mdpi/ic_logout.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-mdpi/ic_profile.png b/amethyst/src/main/res/drawable-mdpi/ic_profile.png deleted file mode 100644 index 8eec4f1e33..0000000000 Binary files a/amethyst/src/main/res/drawable-mdpi/ic_profile.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-mdpi/ic_security.png b/amethyst/src/main/res/drawable-mdpi/ic_security.png deleted file mode 100644 index 3dc8abcced..0000000000 Binary files a/amethyst/src/main/res/drawable-mdpi/ic_security.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-mdpi/ic_theme.png b/amethyst/src/main/res/drawable-mdpi/ic_theme.png deleted file mode 100644 index 616707d87f..0000000000 Binary files a/amethyst/src/main/res/drawable-mdpi/ic_theme.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-mdpi/ic_topics.png b/amethyst/src/main/res/drawable-mdpi/ic_topics.png deleted file mode 100644 index aebed8f6bd..0000000000 Binary files a/amethyst/src/main/res/drawable-mdpi/ic_topics.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-mdpi/ic_trends.png b/amethyst/src/main/res/drawable-mdpi/ic_trends.png deleted file mode 100644 index 3275536afd..0000000000 Binary files a/amethyst/src/main/res/drawable-mdpi/ic_trends.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-mdpi/ic_verified.png b/amethyst/src/main/res/drawable-mdpi/ic_verified.png deleted file mode 100644 index 9542073aba..0000000000 Binary files a/amethyst/src/main/res/drawable-mdpi/ic_verified.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-mdpi/ic_verified_transparent.png b/amethyst/src/main/res/drawable-mdpi/ic_verified_transparent.png deleted file mode 100644 index 8a70ec1d42..0000000000 Binary files a/amethyst/src/main/res/drawable-mdpi/ic_verified_transparent.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-night-hdpi/ic_verified.png b/amethyst/src/main/res/drawable-night-hdpi/ic_verified.png deleted file mode 100644 index 3ad814bc3b..0000000000 Binary files a/amethyst/src/main/res/drawable-night-hdpi/ic_verified.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-night-mdpi/ic_verified.png b/amethyst/src/main/res/drawable-night-mdpi/ic_verified.png deleted file mode 100644 index e95bdec0e3..0000000000 Binary files a/amethyst/src/main/res/drawable-night-mdpi/ic_verified.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-night-xhdpi/ic_verified.png b/amethyst/src/main/res/drawable-night-xhdpi/ic_verified.png deleted file mode 100644 index 411b700ecd..0000000000 Binary files a/amethyst/src/main/res/drawable-night-xhdpi/ic_verified.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-night-xxhdpi/ic_verified.png b/amethyst/src/main/res/drawable-night-xxhdpi/ic_verified.png deleted file mode 100644 index d8605ab150..0000000000 Binary files a/amethyst/src/main/res/drawable-night-xxhdpi/ic_verified.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-night-xxxhdpi/ic_verified.png b/amethyst/src/main/res/drawable-night-xxxhdpi/ic_verified.png deleted file mode 100644 index bbf7b53dc5..0000000000 Binary files a/amethyst/src/main/res/drawable-night-xxxhdpi/ic_verified.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xhdpi/ic_bookmarks.png b/amethyst/src/main/res/drawable-xhdpi/ic_bookmarks.png deleted file mode 100644 index 5b04fca6c1..0000000000 Binary files a/amethyst/src/main/res/drawable-xhdpi/ic_bookmarks.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xhdpi/ic_globe.png b/amethyst/src/main/res/drawable-xhdpi/ic_globe.png deleted file mode 100644 index 1e0e02c227..0000000000 Binary files a/amethyst/src/main/res/drawable-xhdpi/ic_globe.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xhdpi/ic_key.png b/amethyst/src/main/res/drawable-xhdpi/ic_key.png deleted file mode 100644 index 79ffc2e674..0000000000 Binary files a/amethyst/src/main/res/drawable-xhdpi/ic_key.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xhdpi/ic_logout.png b/amethyst/src/main/res/drawable-xhdpi/ic_logout.png deleted file mode 100644 index 10fd5751c3..0000000000 Binary files a/amethyst/src/main/res/drawable-xhdpi/ic_logout.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xhdpi/ic_profile.png b/amethyst/src/main/res/drawable-xhdpi/ic_profile.png deleted file mode 100644 index 67c2513f62..0000000000 Binary files a/amethyst/src/main/res/drawable-xhdpi/ic_profile.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xhdpi/ic_security.png b/amethyst/src/main/res/drawable-xhdpi/ic_security.png deleted file mode 100644 index d8f6c01f2b..0000000000 Binary files a/amethyst/src/main/res/drawable-xhdpi/ic_security.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xhdpi/ic_theme.png b/amethyst/src/main/res/drawable-xhdpi/ic_theme.png deleted file mode 100644 index 59b9eeee3c..0000000000 Binary files a/amethyst/src/main/res/drawable-xhdpi/ic_theme.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xhdpi/ic_topics.png b/amethyst/src/main/res/drawable-xhdpi/ic_topics.png deleted file mode 100644 index f235e5d22e..0000000000 Binary files a/amethyst/src/main/res/drawable-xhdpi/ic_topics.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xhdpi/ic_trends.png b/amethyst/src/main/res/drawable-xhdpi/ic_trends.png deleted file mode 100644 index fb979bb71a..0000000000 Binary files a/amethyst/src/main/res/drawable-xhdpi/ic_trends.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xhdpi/ic_verified.png b/amethyst/src/main/res/drawable-xhdpi/ic_verified.png deleted file mode 100644 index 7234f029f7..0000000000 Binary files a/amethyst/src/main/res/drawable-xhdpi/ic_verified.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xhdpi/ic_verified_transparent.png b/amethyst/src/main/res/drawable-xhdpi/ic_verified_transparent.png deleted file mode 100644 index 27ee31f90a..0000000000 Binary files a/amethyst/src/main/res/drawable-xhdpi/ic_verified_transparent.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xxhdpi/ic_bookmarks.png b/amethyst/src/main/res/drawable-xxhdpi/ic_bookmarks.png deleted file mode 100644 index b810d48cf5..0000000000 Binary files a/amethyst/src/main/res/drawable-xxhdpi/ic_bookmarks.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xxhdpi/ic_globe.png b/amethyst/src/main/res/drawable-xxhdpi/ic_globe.png deleted file mode 100644 index 3e4d76b9bb..0000000000 Binary files a/amethyst/src/main/res/drawable-xxhdpi/ic_globe.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xxhdpi/ic_key.png b/amethyst/src/main/res/drawable-xxhdpi/ic_key.png deleted file mode 100644 index e0cdfef0ab..0000000000 Binary files a/amethyst/src/main/res/drawable-xxhdpi/ic_key.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xxhdpi/ic_logout.png b/amethyst/src/main/res/drawable-xxhdpi/ic_logout.png deleted file mode 100644 index c19029b204..0000000000 Binary files a/amethyst/src/main/res/drawable-xxhdpi/ic_logout.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xxhdpi/ic_profile.png b/amethyst/src/main/res/drawable-xxhdpi/ic_profile.png deleted file mode 100644 index 118d4eb78a..0000000000 Binary files a/amethyst/src/main/res/drawable-xxhdpi/ic_profile.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xxhdpi/ic_security.png b/amethyst/src/main/res/drawable-xxhdpi/ic_security.png deleted file mode 100644 index dba255c73a..0000000000 Binary files a/amethyst/src/main/res/drawable-xxhdpi/ic_security.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xxhdpi/ic_theme.png b/amethyst/src/main/res/drawable-xxhdpi/ic_theme.png deleted file mode 100644 index d58f977f67..0000000000 Binary files a/amethyst/src/main/res/drawable-xxhdpi/ic_theme.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xxhdpi/ic_topics.png b/amethyst/src/main/res/drawable-xxhdpi/ic_topics.png deleted file mode 100644 index d65cc49b3b..0000000000 Binary files a/amethyst/src/main/res/drawable-xxhdpi/ic_topics.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xxhdpi/ic_trends.png b/amethyst/src/main/res/drawable-xxhdpi/ic_trends.png deleted file mode 100644 index 858746bbac..0000000000 Binary files a/amethyst/src/main/res/drawable-xxhdpi/ic_trends.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xxhdpi/ic_verified.png b/amethyst/src/main/res/drawable-xxhdpi/ic_verified.png deleted file mode 100644 index e52a538bc7..0000000000 Binary files a/amethyst/src/main/res/drawable-xxhdpi/ic_verified.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xxhdpi/ic_verified_transparent.png b/amethyst/src/main/res/drawable-xxhdpi/ic_verified_transparent.png deleted file mode 100644 index 76f5390894..0000000000 Binary files a/amethyst/src/main/res/drawable-xxhdpi/ic_verified_transparent.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xxxhdpi/ic_bookmarks.png b/amethyst/src/main/res/drawable-xxxhdpi/ic_bookmarks.png deleted file mode 100644 index 165366df40..0000000000 Binary files a/amethyst/src/main/res/drawable-xxxhdpi/ic_bookmarks.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xxxhdpi/ic_globe.png b/amethyst/src/main/res/drawable-xxxhdpi/ic_globe.png deleted file mode 100644 index 4062798a89..0000000000 Binary files a/amethyst/src/main/res/drawable-xxxhdpi/ic_globe.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xxxhdpi/ic_profile.png b/amethyst/src/main/res/drawable-xxxhdpi/ic_profile.png deleted file mode 100644 index ee8bcf94fa..0000000000 Binary files a/amethyst/src/main/res/drawable-xxxhdpi/ic_profile.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xxxhdpi/ic_theme.png b/amethyst/src/main/res/drawable-xxxhdpi/ic_theme.png deleted file mode 100644 index 432b8953fc..0000000000 Binary files a/amethyst/src/main/res/drawable-xxxhdpi/ic_theme.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xxxhdpi/ic_topics.png b/amethyst/src/main/res/drawable-xxxhdpi/ic_topics.png deleted file mode 100644 index c40d9f9a40..0000000000 Binary files a/amethyst/src/main/res/drawable-xxxhdpi/ic_topics.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xxxhdpi/ic_trends.png b/amethyst/src/main/res/drawable-xxxhdpi/ic_trends.png deleted file mode 100644 index b7a948c49c..0000000000 Binary files a/amethyst/src/main/res/drawable-xxxhdpi/ic_trends.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xxxhdpi/ic_verified.png b/amethyst/src/main/res/drawable-xxxhdpi/ic_verified.png deleted file mode 100644 index 947c365d77..0000000000 Binary files a/amethyst/src/main/res/drawable-xxxhdpi/ic_verified.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xxxhdpi/ic_verified_transparent.png b/amethyst/src/main/res/drawable-xxxhdpi/ic_verified_transparent.png deleted file mode 100644 index 8213c188e7..0000000000 Binary files a/amethyst/src/main/res/drawable-xxxhdpi/ic_verified_transparent.png and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xxxhdpi/profile_banner.jpeg b/amethyst/src/main/res/drawable-xxxhdpi/profile_banner.jpeg deleted file mode 100644 index ee5baffef6..0000000000 Binary files a/amethyst/src/main/res/drawable-xxxhdpi/profile_banner.jpeg and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xxxhdpi/profile_banner.jpg b/amethyst/src/main/res/drawable-xxxhdpi/profile_banner.jpg new file mode 100644 index 0000000000..f0c93eb835 Binary files /dev/null and b/amethyst/src/main/res/drawable-xxxhdpi/profile_banner.jpg differ diff --git a/amethyst/src/main/res/drawable/following.xml b/amethyst/src/main/res/drawable/following.xml deleted file mode 100644 index 60c2c4395e..0000000000 --- a/amethyst/src/main/res/drawable/following.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - diff --git a/amethyst/src/main/res/drawable/ic_settings.xml b/amethyst/src/main/res/drawable/ic_settings.xml deleted file mode 100644 index f783dd69b9..0000000000 --- a/amethyst/src/main/res/drawable/ic_settings.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/amethyst/src/main/res/drawable/ic_tor.xml b/amethyst/src/main/res/drawable/ic_tor.xml index bafee0c19c..7a8f25b5eb 100644 --- a/amethyst/src/main/res/drawable/ic_tor.xml +++ b/amethyst/src/main/res/drawable/ic_tor.xml @@ -1,4 +1,9 @@ - - + + diff --git a/amethyst/src/main/res/drawable/lyrics_off.xml b/amethyst/src/main/res/drawable/lyrics_off.xml deleted file mode 100644 index 763ec6fc1f..0000000000 --- a/amethyst/src/main/res/drawable/lyrics_off.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - diff --git a/amethyst/src/main/res/drawable/lyrics_on.xml b/amethyst/src/main/res/drawable/lyrics_on.xml deleted file mode 100644 index c4a17de8bd..0000000000 --- a/amethyst/src/main/res/drawable/lyrics_on.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/amethyst/src/main/res/drawable/manage_accounts.xml b/amethyst/src/main/res/drawable/manage_accounts.xml deleted file mode 100644 index 734c705597..0000000000 --- a/amethyst/src/main/res/drawable/manage_accounts.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/amethyst/src/main/res/drawable/nip_05_full.xml b/amethyst/src/main/res/drawable/nip_05_full.xml deleted file mode 100644 index 07a77fd8b8..0000000000 --- a/amethyst/src/main/res/drawable/nip_05_full.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - diff --git a/amethyst/src/main/res/drawable/text_select_move_forward_character.xml b/amethyst/src/main/res/drawable/text_select_move_forward_character.xml deleted file mode 100644 index 312d40dbcd..0000000000 --- a/amethyst/src/main/res/drawable/text_select_move_forward_character.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/amethyst/src/main/res/drawable/x.xml b/amethyst/src/main/res/drawable/x.xml index 5548bf909c..fa6470d042 100644 --- a/amethyst/src/main/res/drawable/x.xml +++ b/amethyst/src/main/res/drawable/x.xml @@ -6,4 +6,4 @@ - \ No newline at end of file + diff --git a/amethyst/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/amethyst/src/main/res/mipmap-anydpi/ic_launcher.xml similarity index 100% rename from amethyst/src/main/res/mipmap-anydpi-v26/ic_launcher.xml rename to amethyst/src/main/res/mipmap-anydpi/ic_launcher.xml diff --git a/amethyst/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/amethyst/src/main/res/mipmap-anydpi/ic_launcher_round.xml similarity index 100% rename from amethyst/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml rename to amethyst/src/main/res/mipmap-anydpi/ic_launcher_round.xml diff --git a/amethyst/src/main/res/values-ar-rSA/strings.xml b/amethyst/src/main/res/values-ar-rSA/strings.xml index 590b91ae81..20539cee1b 100644 --- a/amethyst/src/main/res/values-ar-rSA/strings.xml +++ b/amethyst/src/main/res/values-ar-rSA/strings.xml @@ -90,7 +90,7 @@ مجموعتي الرائعة رابط الصورة الوصف - "معلومات عن القناة.." + "معلومات عن القناة…" ما الذي يدور في ذهنك؟ نشر حفظ @@ -132,7 +132,7 @@ تحميل الصورة جاري التحميل… لا يمتلك المستخدم عنوان (Lightning Address) لاستقبال sats - "الرد هنا.. " + "الرد هنا…" نسخ معرف الملاحظة إلى الحافظة للمشاركة انسخ معرف القناة (ملاحظة) إلى الحافظة تعديل البيانات الوصفية للقناة @@ -274,7 +274,6 @@ الغاء المتابعة متابعة إزالته من المعرض - إزالة هذه الوسيطة من المعرض. طلب حذفها سيطلب Amethyst حذف ملاحظتك من المرحلات المتصل بها حاليا، لا يوجد ضمان لحذفها من هذه المرحلات أو من المرحلات الأخرى التي ربما حفظت فيها. حظر @@ -476,7 +475,6 @@ تسجيل الخروج سوف يحذف معلوماتك من على الجهاز. تأكد من نسخ مفتاحك الخاص لتجنب فقدان الحساب. هل تريد ألاستمرار؟ العلامات المُتابعة مرحلات - مجتمع االدردشات المنشورات الموافق عليها هذه المجموعة ليس لديها وصف أو قواعد. diff --git a/amethyst/src/main/res/values-bn-rBD/strings.xml b/amethyst/src/main/res/values-bn-rBD/strings.xml index afaf5ef7fd..c4376da19f 100644 --- a/amethyst/src/main/res/values-bn-rBD/strings.xml +++ b/amethyst/src/main/res/values-bn-rBD/strings.xml @@ -33,6 +33,7 @@ ছদ্মবেশ ধারণের অভিযোগ করুন খোলামেলা আধেয় হিসেবে অভিযোগ করুন বেআইনী আচরণের অভিযোগ করুন + মোড প্রতিউত্তর দিতে ব্যক্তিগত চাবি দিয়ে লগ ইন করুন পোস্টগুলি বুস্ট করতে ব্যক্তিগত চাবি দিয়ে লগ ইন করুন পোস্টগুলি লাইক করতে ব্যক্তিগত চাবি দিয়ে লগ ইন করুন @@ -59,6 +60,8 @@ সফলভাবে মূল্য পরিশোধিত হয়েছে " অনুসরণ" " অনুসারী" + "%1$s অনুসরণ" + "" প্রোফাইল নিরাপত্তা-ফিল্টার লগ আউট @@ -70,6 +73,8 @@ আপনাকে অনেক ধন্যবাদ! স্যাটে মোট পরিমাণ স্যাট পাঠান + সিক্রেট ইমোজি মেকার + 😎 "এই দুইয়ের পূর্বরূপ দেখানো যাচ্ছে না %1$s : %2$s" "%1$s এর জন্য কার্ড ইমেজের পূর্বরূপ দেখুন" নতুন চ্যানেল @@ -77,7 +82,7 @@ আমার অসাধারণ দল ছবির Url বিবরণ - "আমাদের সম্পর্কে.. " + "আমাদের সম্পর্কে…" নিজের কোনো ভাবনা প্রকাশ করতে চান? পেশ করুন সংরক্ষণ করুন @@ -416,8 +421,6 @@ লগ আউট করলে আপনার সমস্ত স্থানীয় তথ্য মুছে যাবে। অ্যাকাউন্ট সুরক্ষিত রাখতে আপনার ব্যক্তিগত চাবিটি নিরাপদে সংরক্ষিত আছে কিনা নিশ্চিত হোন। সামনে আগাতে চান? অনুসৃত ট্যাগগুলি রিলেগুলি - লাইভ - কম্যুনিটি বার্তালাপগুলি অনুমোদিত পোস্টগুলি এই দলটির কোনো বিবরণ কিংবা নীতিমালা নেই। এগুলো যুক্ত করতে দলনেতার সাথে কথা বলুন @@ -513,7 +516,7 @@ কোনটি নয় বিক্রেতাকে একটি বার্তা পাঠান হাই %1$s, এটা কি এখনও পাওয়া যায়? - হাই %1$s, এটা কি এখনও পাওয়া যায়? + হাই, এটা কি এখনও পাওয়া যায়? একটি পণ্য বিক্রি করুন শিরোনাম iPhone 13 diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index f9d54bc872..567ef893e1 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -3,9 +3,9 @@ Nasměrujte na QR kód Zobrazit QR kód Profilový obrázek - Profilový obrázek + Váš profilový obrázek Skenovat QR kód - Zobrazit přesto + Přesto zobrazit Tento příspěvek byl skryt, protože zmiňuje vaše skryté uživatele nebo slova Příspěvek byl označen jako nevhodný uživatelem Příspěvek nenalezen @@ -51,6 +51,13 @@ Přihlaste se pomocí soukromého klíče, abyste mohli zrušit sledování Používáte veřejný klíč a veřejné klíče jsou pouze pro čtení. Přihlaste se pomocí soukromého klíče, abyste mohli skrýt slovo nebo větu Používáte veřejný klíč a veřejné klíče jsou pouze pro čtení. Přihlaste se pomocí soukromého klíče, abyste mohli zobrazit slovo nebo větu + Používáte veřejný klíč, který je pouze pro čtení. Přihlaste se pomocí soukromého klíče, abyste mohli měnit nastavení + Používáte veřejný klíč, který je pouze pro čtení. Přihlaste se pomocí soukromého klíče, abyste mohli nahrávat + Používáte veřejný klíč, který je pouze pro čtení. Přihlaste se pomocí soukromého klíče, abyste mohli podepisovat události + Neautorizované dešifrování + Podepisovatel neautorizoval dešifrování potřebné k provedení této operace. Aktivujte dešifrování NIP-44 ve své aplikaci pro podepisování a zkuste to znovu + Podepisovatel nenalezen + Byla aplikace pro podepisování odinstalována? Zkontrolujte, zda je aplikace nainstalována a obsahuje tento účet. Odhlaste se a přihlaste znovu, pokud se aplikace změnila. Zapy Počet zobrazení Zvýšení @@ -71,6 +78,8 @@ Chyba při zpracování chybové zprávy " Sleduje" " Sledující" + "%1$s následuje" + "%1$s Sledujících" Profil Bezpečnostní filtry Odhlásit @@ -96,9 +105,10 @@ Má skvělá skupina URL obrázku Popis - "O nás.. " + Popis nebyl nalezen + "O nás…" Co vás napadá? - Napište zprávu... + Napište zprávu… Příspěvek Uložit Vytvořit @@ -137,9 +147,13 @@ Video uloženo do video galerie telefonu Nepodařilo se uložit video Nahrát obrázek + Pořiďte fotografii + Nahrajte zprávu + Nahrajte zprávu + Stiskněte a podržte pro nahrání zprávy Nahrávání… Uživatel nemá nastavenou LN adresu pro přijímání sats - "Odpověď zde.. " + "Odpověď zde…" Zkopíruje ID poznámky do schránky pro sdílení Zkopírovat ID kanálu (poznámka) do schránky Upravit metadata kanálu @@ -155,6 +169,7 @@ Galerie "Sleduje" "Zprávy" + "%1$s Reportů" Více možností " Přeposílání" Webová stránka @@ -213,14 +228,23 @@ Přestat sledovat Kanál vytvořen "Informace kanálu změněna na" + Mizící chat + Relé chat + Relé chaty + Relay chaty jsou chatové skupiny ovládané jejich domácí relé. + Jsou viditelné pro všechny na Nostru a každý se jich může zúčastnit. + Jsou skvělé pro otevřené komunity kolem konkrétních témat. Některé z těchto skupin jsou efemérní + a proto zprávy časem mizí Veřejný chat Metadata veřejného chatu Veřejné konverzace jsou viditelné pro všechny na Nostru a kdokoli se na nich může podílet. Jsou skvělé pro otevřené komunity kolem konkrétních témat. Moderaci lze ovládat smazáním příspěvků na relé Rele - Vložte mezi 1-3 relé, které hostí tuto skupinu. + Vložte mezi 1–3 relé, které hostí tuto skupinu. Klienti Nostr používají toto nastavení, aby věděli, kam stahovat zprávy a odesílat zprávy. + Placený Relé + Vynutit Tor při připojení příspěvků přijato Odebrat Automaticky @@ -263,6 +287,7 @@ Chyba "Vytvořeno uživatelem %1$s" "Obrázek ocenění od %1$s" + Obrázek ocenění Obdrželi jste nové ocenění Ocenění uděleno uživateli Text poznámky zkopírován do schránky @@ -288,7 +313,7 @@ Přestat sledovat Sledovat Odstranit z galerie - Odstraňte tato média z vaší galerie, můžete je přidat později + Odstranit toto médium z vaší galerie. Požadavek na smazání Amethyst požádá o smazání vaší poznámky ze spojek, ke kterým jste v současnosti připojeni. Není zaručeno, že vaše poznámka bude trvale smazána z těchto spojek nebo z dalších spojek, kde může být uložena. Blokovat @@ -410,6 +435,7 @@ Ne Seznam sledovaných Všechna sledování + Sleduje přes proxy Kolem mě Globální Seznam ztlumení @@ -484,9 +510,18 @@ Vždy skrýt citlivý obsah Vždy zobrazit citlivý obsah Vždy zobrazovat upozornění na obsah + Skrýt + Ukázat + Varovat Doporučuje: Filtrovat spam od cizinců Varovat, když příspěvky obsahují hlášení od vašich sledovaných osob + Filtrovat spam + Skryje příspěvky od cizinců, které byly přesně stejné 5 a více krát + Upozornit na hlášení + Zobrazí varovnou zprávu, když příspěvky mají 5 nebo více hlášení od vašich sledovaných + Zobrazit citlivý obsah + Zobrazí varovnou zprávu, když ji autor příspěvku označil jako citlivý Nový symbol reakce Nebyly vybrány žádné typy reakcí. Dlouhým stiskem změňte Zap-sběr @@ -538,10 +573,12 @@ Odhlášení vymaže všechny vaše místní informace. Ujistěte se, že máte zálohované své privátní klíče, abyste se vyhnuli ztrátě účtu. Chcete pokračovat? Sledované značky Rele - Objevování poznámek + Balíčky Sledování + Přečtené + Algoritmy kanálu Trh - Živě - Komunita + Živé vysílání + Komunity Chaty Schválené příspěvky Tato skupina nemá popis ani pravidla. Promluvte si s majitelem, aby je přidal/a. @@ -549,6 +586,7 @@ Citlivý obsah Před zobrazením tohoto obsahu přidá upozornění na citlivý obsah. Nastavení aplikace + Uživatelské nastavení Nastavení Vždy Pouze Wi-Fi @@ -593,6 +631,8 @@ Přidá Geohash vaší polohy do příspěvku. Veřejnost bude vědět, že se nacházíte do 5 km od aktuální polohy Lokace-Exkluzívní příspěvek Uvidí to pouze následovníci umístění. Tvoji obecní následovníci to neuvidí. + Hashtag-exkluzivní příspěvek + Uvidí to pouze následovníci hashtagu. Tvůj všeobecní následovníci to neuvidí. Načítání umístění Žádná lokace oprávnění Přidat varování o citlivém obsahu před zobrazením vašeho obsahu. Toto je ideální pro obsah NSFW (nebezpečné pro práci) nebo obsah, který někteří lidé mohou považovat za urážlivý nebo znepokojující @@ -601,6 +641,7 @@ Aktivovat Veřejné Nová veřejná nebo soukromá skupina + Relé Soukromé Pro Předmět @@ -666,6 +707,7 @@ Peněženka %1$s Chyba při otevírání aplikace pro podpis Signner aplikace nebyla nalezena. Zkontrolujte, zda aplikace nebyla odinstalována + Požadavek na podpis byl zamítnut Žádost o podpis byla zamítnuta Ujistěte se, že podepisující aplikace autorizovala tuto transakci Nebyly nalezeny žádné peněženky pro platbu bleskové faktury (Chyba: %1$s). Prosím, nainstalujte si bleskovou peněženku pro použití Zapů @@ -793,6 +835,8 @@ Nový příspěvek Nové Shorts: obrázky nebo videa Nová poznámka komunity + Nový produkt + Nový geoexkluzivní příspěvek Otevřít všechny reakce pro tento příspěvek Zavřít všechny reakce na tento příspěvek Odpověď @@ -847,12 +891,29 @@ Vložte mezi 1–3 relé pro ukládání událostí nikoho jiného, jako jsou koncepty a/nebo nastavení aplikace. V ideálním případě jsou tato relé buď lokální, nebo vyžadují autentizaci před stažením obsahu každého uživatele. Obecná relé Amethyst používá tato relé ke stahování příspěvků pro vás. + Připojené přenašeče + Aktuální seznam používaných přenašečů Doporučené relé Pro příjem příspěvků od uvedených uživatelů přidejte následující relé do seznamu obecných relat. Vyhledávací relé Seznam relé, která se používají k vyhledávání a označování uživatelů. Označování a vyhledávání nebude fungovat, pokud nejsou k dispozici žádné možnosti. Lokální relé Seznam relací, které jsou v tomto zařízení spuštěny. + Důvěryhodné Relé + Důvěryhodné přenašeče + Přenašeče, kterým důvěřujete a nepotřebujete Tor + Proxy Relé + Proxy přenašeče + Agregační přenašeče, které aplikace používá ke stahování vašich zdrojů, jako např. filter.nostr.wine + Vysílací Relé + Vysílací Relé + Relé, které šíří vaše poznámky do ostatních přenašečů, jako např. sendit.nosflare.com + Indexační Relé + Indexační Relé + Relé, které uchovávají metadata a seznamy přenašečů, např. purplepag.es + Blokované Relé + Blokované Relé + Amethyst se k těmto relé nikdy nepřipojí Zapni vývojáře! Váš příspěvek nám pomáhá dělat rozdíl. Každý sat se počítá! Přispět nyní @@ -942,4 +1003,11 @@ Vyberte seznam pro filtrování kanálu Odhlásit se na zámek zařízení Soukromá zpráva + Veřejná zpráva + Relé chatu + Relé, ke kterému se všichni uživatelé tohoto chatu připojují + Sdílet obrázek… + Vyhledávání hashtag: #%1$s + Nepřekládat z + Zde zobrazené jazyky nebudou přeloženy. Vyberte jazyk, který chcete odstranit a nechat je znovu přeložit. diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index 1da6feca07..fc85f6d271 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -96,7 +96,7 @@ Má skvělá skupina URL obrázku Popis - "O nás.. " + "O nás…" Co vás napadá? Příspěvek Uložit @@ -138,7 +138,7 @@ Nahrát obrázek Nahrávání… Uživatel nemá nastavenou LN adresu pro přijímání sats - "Odpověď zde.. " + "Odpověď zde…" Zkopíruje ID poznámky do schránky pro sdílení Zkopírovat ID kanálu (poznámka) do schránky Upravit metadata kanálu @@ -217,7 +217,7 @@ se na nich může podílet. Jsou skvělé pro otevřené komunity kolem konkrétních témat. Moderaci lze ovládat smazáním příspěvků na relé Rele - Vložte mezi 1-3 relé, které hostí tuto skupinu. + Vložte mezi 1–3 relé, které hostí tuto skupinu. Klienti Nostr používají toto nastavení, aby věděli, kam stahovat zprávy a odesílat zprávy. příspěvků přijato Odebrat @@ -286,7 +286,6 @@ Přestat sledovat Sledovat Odstranit z galerie - Odstraňte tato média z vaší galerie, můžete je přidat později Požadavek na smazání Amethyst požádá o smazání vaší poznámky ze spojek, ke kterým jste v současnosti připojeni. Není zaručeno, že vaše poznámka bude trvale smazána z těchto spojek nebo z dalších spojek, kde může být uložena. Blokovat @@ -535,10 +534,7 @@ Odhlášení vymaže všechny vaše místní informace. Ujistěte se, že máte zálohované své privátní klíče, abyste se vyhnuli ztrátě účtu. Chcete pokračovat? Sledované značky Rele - Objevování poznámek Trh - Živě - Komunita Chaty Schválené příspěvky Tato skupina nemá popis ani pravidla. Promluvte si s majitelem, aby je přidal/a. @@ -936,4 +932,5 @@ Pro otevření a stažení souboru nejsou nainstalovány žádné torrent aplikace. Vyberte seznam pro filtrování kanálu Odhlásit se na zámek zařízení + Sdílet obrázek… diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index cca2c8538a..d9aea16725 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -51,6 +51,13 @@ Sie verwenden einen öffentlichen Schlüssel und öffentliche Schlüssel sind schreibgeschützt. Mit einem privaten Schlüssel anmelden, um nicht mehr zu folgen Sie verwenden einen öffentlichen Schlüssel, und öffentliche Schlüssel sind nur lesbar. Melden Sie sich mit einem privaten Schlüssel an, um ein Wort oder einen Satz auszublenden Sie verwenden einen öffentlichen Schlüssel und öffentliche Schlüssel sind schreibgeschützt. Mit einem privaten Schlüssel anmelden, um ein Wort oder einen Satz anzeigen zu können + Du verwendest einen öffentlichen Schlüssel, der nur lesbar ist. Melde dich mit einem privaten Schlüssel an, um Einstellungen ändern zu können + Du verwendest einen öffentlichen Schlüssel, der nur lesbar ist. Melde dich mit einem privaten Schlüssel an, um hochladen zu können + Du verwendest einen öffentlichen Schlüssel, der nur lesbar ist. Melde dich mit einem privaten Schlüssel an, um Ereignisse zu signieren + Unbefugte Entschlüsselung + Der Signierer hat die erforderliche Entschlüsselung nicht autorisiert. Aktiviere NIP-44-Entschlüsselung in deiner Signierer-App und versuche es erneut + Signierer nicht gefunden + Wurde die Signierer-App deinstalliert? Überprüfe, ob sie installiert ist und dieses Konto enthält. Melde dich ab und wieder an, falls sich die App geändert hat. Zaps Aufrufe Boost @@ -71,6 +78,8 @@ Fehler beim Parsen der Fehlermeldung " Folgen" " Anhänger" + "%1$s Folgen" + "%1$s Anhänger" Profil Sicherheitsfilter Ausloggen @@ -96,9 +105,10 @@ Meine tolle Gruppe Bild-URL Beschreibung - "Über uns.. " + Keine Beschreibung gefunden + "Über uns…" Was beschäftigt dich? - Eine Nachricht schreiben... + Eine Nachricht schreiben… Beitrag Speichern Erstellen @@ -139,9 +149,13 @@ erie gespeichert Video in der Videogalerie des Telefons gespeichert Video konnte nicht gespeichert werden Bild hochladen + Ein Foto aufnehmen + Eine Nachricht aufnehmen + Eine Nachricht aufnehmen + Zum Aufnehmen einer Nachricht gedrückt halten Hochladen… Der Benutzer hat keine Lightning-Adresse eingerichtet, um Sats zu empfangen - "Hier antworten.. " + "Hier antworten…" Kopiert die Notiz-ID zum Teilen in die Zwischenablage Kopiere Kanal-ID (Notiz) in die Zwischenablage Bearbeitet die Kanalmetadaten @@ -157,6 +171,7 @@ erie gespeichert Galerie "Folgt" "Berichte" + "%1$s Meldungen" Weitere Optionen " Relais" Webseite @@ -217,14 +232,23 @@ anz der Bedingungen ist erforderlich Entfolgen Kanal erstellt "Kanalinformationen geändert in" + Verschwindender Chat + Relay-Chat + Relay-Chats + Relaischats sind Chatgruppen, die von ihrem Home-Relais gesteuert werden. + Sie sind für jeden auf Nostr sichtbar und jeder kann an ihnen teilnehmen. + Sie eignen sich hervorragend für offene Communities rund um bestimmte Themen. Einige dieser Gruppen sind kurzsichtig + und daher verschwinden Chat-Nachrichten im Laufe der Zeit Öffentlicher Chat Öffentliche Chat Metadaten Öffentliche Chats sind für jeden auf Nostr sichtbar und jeder kann daran teilnehmen. Sie eignen sich hervorragend für offene Gemeinschaften rund um bestimmte Themen. Moderation kann durch Löschen von Beiträgen auf den Relais gesteuert werden Relais - Fügen Sie zwischen 1-3 Relais ein, die diese Gruppe beherbergen. + Fügen Sie zwischen 1–3 Relais ein, die diese Gruppe beherbergen. Nostr Clients verwenden diese Einstellung, um zu wissen, woher sie Nachrichten herunterladen und an welche gesendet werden sollen. + Bezahltes Relay + Tor-Verbindung erzwingen empfangene Beiträge Entfernen Automatisch @@ -267,6 +291,7 @@ anz der Bedingungen ist erforderlich Fehler "Erstellt von %1$s" "Auszeichnungsbild für %1$s" + Auszeichnungsbild Sie haben eine neue Auszeichnung erhalten Auszeichnung verliehen an Notiztext in die Zwischenablage kopiert @@ -292,7 +317,7 @@ anz der Bedingungen ist erforderlich Entfolgen Folgen Aus Galerie löschen - Entferne diese Medien von deiner Galerie, du kannst sie später hinzufügen + Entferne dieses Medium von deiner Galerie. Löschung anfordern Amethyst wird beantragen, dass Ihre Notiz von den Relays gelöscht wird, mit denen Sie derzeit verbunden sind. Es gibt keine Garantie dafür, dass Ihre Notiz dauerhaft von diesen Relays oder anderen Relays, in denen sie gespeichert sein kann, gelöscht wird. Blockieren @@ -353,7 +378,7 @@ anz der Bedingungen ist erforderlich Die Umfrage ist für neue Stimmen geschlossen Zap-Betrag Es ist nur eine Stimme pro Benutzer für diesen Umfragetyp erlaubt - "Veranstaltung suchen" + "Veranstaltung suchen %1$s" Füge eine öffentliche Nachricht hinzu Füge eine private Nachricht hinzu Füge eine Rechnungsnachricht hinzu @@ -416,6 +441,7 @@ anz der Bedingungen ist erforderlich Nein Folgen-Liste Alle Folgen + Folgt über Proxy In der Nähe Weltweit Stummliste @@ -489,9 +515,18 @@ anz der Bedingungen ist erforderlich Sensiblen Inhalt immer ausblenden Sensiblen Inhalt immer anzeigen Immer Inhaltswarnungen anzeigen + Ausblenden + Anzeigen + Warnen Empfohlene Apps: Spam von Fremden filtern Warnung bei Meldungen von deinen Abonnements + Spam filtern + Versteckt Beiträge von Fremden, die 5 oder mehr Male genau die gleichen waren + Bei Berichten warnen + Zeigt eine Warnmeldung an, wenn die Beiträge 5 oder mehr Berichte von den folgenden Beiträgen enthalten + Heiklen Inhalt anzeigen + Zeigt eine Warnmeldung an, wenn der Autor des Beitrags ihn als sensibel markiert hat Neues Reaktionssymbol Keine Reaktionstypen ausgewählt. Lange drücken, um zu ändern Zap-Sammlung @@ -543,10 +578,12 @@ anz der Bedingungen ist erforderlich Das Abmelden löscht alle Ihre lokalen Informationen. Stellen Sie sicher, dass Sie Ihre privaten Schlüssel gesichert haben, um einen Kontoverlust zu vermeiden. Möchten Sie fortfahren? Gefolgte Tags Relais - Notizen Entdeckung + Folge Paketen + Lesungen + Feedalgorithmen Marktplatz - Live - Gemeinschaft + Live-Streams + Gemeinschaften Plaudern Genehmigte Beiträge Diese Gruppe hat keine Beschreibung oder Regeln. Sprechen Sie mit dem Eigentümer, um eine hinzuzufügen. @@ -554,6 +591,7 @@ anz der Bedingungen ist erforderlich Sensibler Inhalt Fügt eine Warnung für sensiblen Inhalt hinzu, bevor dieser Inhalt angezeigt wird. App Einstellungen + Benutzereinstellungen Einstellungen Immer Nur WLAN @@ -598,6 +636,8 @@ anz der Bedingungen ist erforderlich Fügt dem Beitrag einen Geohash Ihres Standorts hinzu. Die Öffentlichkeit wird wissen, dass Sie sich innerhalb von 5 km (3 mi) vom aktuellen Standort befinden Standort-exklusiver Beitrag Nur Anhänger des Ortes werden es sehen. Deine allgemeinen Anhänger werden es nicht sehen. + Hashtag-exklusive Beitrag + Nur die Anhänger des Hashtags werden ihn sehen. Deine allgemeinen Follower werden ihn nicht sehen. Standort wird geladen Keine Standortberechtigungen Fügt eine Warnung für sensiblen Inhalt hinzu, bevor Ihr Inhalt angezeigt wird. Dies ist ideal für NSFW-Inhalte (nicht sicher für die Arbeit) oder Inhalte, die manche Menschen als anstößig oder verstörend empfinden könnten @@ -606,6 +646,7 @@ anz der Bedingungen ist erforderlich Aktivieren Öffentlich Neue öffentliche oder private Gruppe + Relais Privat An Betreff @@ -671,6 +712,7 @@ anz der Bedingungen ist erforderlich Brieftasche %1$s Fehler beim Öffnen der Signatur-App Die Unterzeichner-App konnte nicht gefunden werden. Prüfen Sie, ob die App nicht deinstalliert wurde + Signaturanfrage abgelehnt Signaturanfrage abgelehnt Stellen Sie sicher, dass die Unterzeichner-Anwendung diese Transaktion autorisiert hat Keine Wallets gefunden, um eine Lightning-Rechnung zu bezahlen (Fehler: %1$s). Installieren Sie eine Lightning-Wallet, um Zaps zu verwenden @@ -798,6 +840,8 @@ anz der Bedingungen ist erforderlich Neuer Beitrag Neue Kurzfilme: Bilder oder Videos Neue Community-Notiz + Neues Produkt + Neuer Geo-Exklusiver Beitrag Alle Reaktionen auf diesen Beitrag öffnen Alle Reaktionen auf diesen Beitrag schließen Antworten @@ -852,12 +896,29 @@ anz der Bedingungen ist erforderlich Fügen Sie zwischen 1–3 Relais ein, um Ereignisse zu speichern, die niemand anders sehen kann, wie Ihre Entwürfe und/oder App-Einstellungen. Idealerweise sind diese Relais entweder lokal oder erfordern eine Authentifizierung, bevor Sie die Inhalte eines jeden Benutzers herunterladen. Allgemeine Relais Amethyst verwendet diese Relais, um Beiträge für Sie herunterzuladen. + Verbundene Relays + Aktuelle Liste der verwendeten Relays Empfohlene Relais Fügen Sie die folgenden Relais zu Ihrer Liste hinzu, um Beiträge von den aufgelisteten Benutzern zu erhalten. Suchrelais Liste der Relais, die für die Suche und das Taggen von Benutzern verwendet werden. Das Taggen und die Suche funktionieren nicht, wenn keine Optionen verfügbar sind. Lokale Relais Liste der Relays, die auf diesem Gerät laufen. + Vertrauenswürdige Relays + Vertrauenswürdige Relays + Relays, denen du vertraust und für die keine Tor-Verbindung erforderlich ist + Proxy-Relays + Proxy-Relays + Aggregator-Relays wie filter.nostr.wine, von denen die App deine Feeds herunterlädt + Broadcast-Relays + Broadcast-Relays + Relays, die deine Beiträge an andere Relays weiterleiten, z.B. sendit.nosflare.com. Amethyst fügt diesen Relay zu allen neuen Ereignissen hinzu + Indexer-Relays + Indexer-Relays + Relays, die Metadaten und Relay-Listen hosten, z.B. purplepag.es. Amethyst verwendet sie, um Benutzer zu finden, die nicht in deinen Listen sind. + Blockierte Relays + Blockierte Relays + Amethyst wird sich niemals mit diesen Relays verbinden Zap die Entwickler! Deine Spende hilft uns, einen Unterschied zu machen. Jeder Sat zählt! Jetzt spenden @@ -947,4 +1008,11 @@ anz der Bedingungen ist erforderlich Liste zum Filtern des Feeds auswählen Beim Sperren des Geräts abmelden Private Nachricht + Öffentliche Nachricht + Chat-Relais + Das Relais, mit dem sich alle Benutzer dieses Chats verbinden + Bild teilen… + Suche Hashtag: #%1$s + Nicht übersetzen von + Die hier angezeigten Sprachen werden nicht übersetzt. Wählen Sie eine Sprache, um sie zu entfernen und lassen Sie sie erneut übersetzen. diff --git a/amethyst/src/main/res/values-de/strings.xml b/amethyst/src/main/res/values-de/strings.xml index 5e2dee8be8..c7e84846a4 100644 --- a/amethyst/src/main/res/values-de/strings.xml +++ b/amethyst/src/main/res/values-de/strings.xml @@ -96,7 +96,7 @@ Meine tolle Gruppe Bild-URL Beschreibung - "Über uns.. " + "Über uns…" Was beschäftigt dich? Beitrag Speichern @@ -140,7 +140,7 @@ erie gespeichert Bild hochladen Hochladen… Der Benutzer hat keine Lightning-Adresse eingerichtet, um Sats zu empfangen - "Hier antworten.. " + "Hier antworten…" Kopiert die Notiz-ID zum Teilen in die Zwischenablage Kopiere Kanal-ID (Notiz) in die Zwischenablage Bearbeitet die Kanalmetadaten @@ -221,7 +221,7 @@ anz der Bedingungen ist erforderlich kann daran teilnehmen. Sie eignen sich hervorragend für offene Gemeinschaften rund um bestimmte Themen. Moderation kann durch Löschen von Beiträgen auf den Relais gesteuert werden Relais - Fügen Sie zwischen 1-3 Relais ein, die diese Gruppe beherbergen. + Fügen Sie zwischen 1–3 Relais ein, die diese Gruppe beherbergen. Nostr Clients verwenden diese Einstellung, um zu wissen, woher sie Nachrichten herunterladen und an welche gesendet werden sollen. empfangene Beiträge Entfernen @@ -290,7 +290,6 @@ anz der Bedingungen ist erforderlich Entfolgen Folgen Aus Galerie löschen - Entferne diese Medien von deiner Galerie, du kannst sie später hinzufügen Löschung anfordern Amethyst wird beantragen, dass Ihre Notiz von den Relays gelöscht wird, mit denen Sie derzeit verbunden sind. Es gibt keine Garantie dafür, dass Ihre Notiz dauerhaft von diesen Relays oder anderen Relays, in denen sie gespeichert sein kann, gelöscht wird. Blockieren @@ -351,7 +350,7 @@ anz der Bedingungen ist erforderlich Die Umfrage ist für neue Stimmen geschlossen Zap-Betrag Es ist nur eine Stimme pro Benutzer für diesen Umfragetyp erlaubt - "Veranstaltung suchen" + "Veranstaltung suchen %1$s" Füge eine öffentliche Nachricht hinzu Füge eine private Nachricht hinzu Füge eine Rechnungsnachricht hinzu @@ -540,10 +539,7 @@ anz der Bedingungen ist erforderlich Das Abmelden löscht alle Ihre lokalen Informationen. Stellen Sie sicher, dass Sie Ihre privaten Schlüssel gesichert haben, um einen Kontoverlust zu vermeiden. Möchten Sie fortfahren? Gefolgte Tags Relais - Notizen Entdeckung Marktplatz - Live - Gemeinschaft Plaudern Genehmigte Beiträge Diese Gruppe hat keine Beschreibung oder Regeln. Sprechen Sie mit dem Eigentümer, um eine hinzuzufügen. @@ -941,4 +937,40 @@ anz der Bedingungen ist erforderlich Keine Torrent-Apps installiert, um die Datei zu öffnen und herunterzuladen. Liste zum Filtern des Feeds auswählen Beim Sperren des Geräts abmelden + Bild teilen… + Du verwendest einen öffentlichen Schlüssel, der nur lesbar ist. Melde dich mit einem privaten Schlüssel an, um Einstellungen ändern zu können + Du verwendest einen öffentlichen Schlüssel, der nur lesbar ist. Melde dich mit einem privaten Schlüssel an, um hochladen zu können + Du verwendest einen öffentlichen Schlüssel, der nur lesbar ist. Melde dich mit einem privaten Schlüssel an, um Ereignisse zu signieren + Unbefugte Entschlüsselung + Der Signierer hat die erforderliche Entschlüsselung nicht autorisiert. Aktiviere NIP-44-Entschlüsselung in deiner Signierer-App und versuche es erneut + Signierer nicht gefunden + Wurde die Signierer-App deinstalliert? Überprüfe, ob sie installiert ist und dieses Konto enthält. Melde dich ab und wieder an, falls sich die App geändert hat. + Keine Beschreibung gefunden + Ein Foto aufnehmen + Eine Nachricht aufnehmen + Eine Nachricht aufnehmen + Zum Aufnehmen einer Nachricht gedrückt halten + Bezahltes Relay + Tor-Verbindung erzwingen + Auszeichnungsbild + Folgt über Proxy + Signaturanfrage abgelehnt + Verbundene Relays + Aktuelle Liste der verwendeten Relays + Vertrauenswürdige Relays + Vertrauenswürdige Relays + Relays, denen du vertraust und für die keine Tor-Verbindung erforderlich ist + Proxy-Relays + Proxy-Relays + Aggregator-Relays wie filter.nostr.wine, von denen die App deine Feeds herunterlädt + Broadcast-Relays + Broadcast-Relays + Relays, die deine Beiträge an andere Relays weiterleiten, z.B. sendit.nosflare.com. Amethyst fügt diesen Relay zu allen neuen Ereignissen hinzu + Indexer-Relays + Indexer-Relays + Relays, die Metadaten und Relay-Listen hosten, z.B. purplepag.es. Amethyst verwendet sie, um Benutzer zu finden, die nicht in deinen Listen sind. + Blockierte Relays + Blockierte Relays + Amethyst wird sich niemals mit diesen Relays verbinden + Öffentliche Nachricht diff --git a/amethyst/src/main/res/values-el-rGR/strings.xml b/amethyst/src/main/res/values-el-rGR/strings.xml index 52ddab765e..9b6fa49fc7 100644 --- a/amethyst/src/main/res/values-el-rGR/strings.xml +++ b/amethyst/src/main/res/values-el-rGR/strings.xml @@ -70,7 +70,7 @@ Η υπέροχη Ομάδα μου Διεύθυνση Εικόνας Url Περιγραφή - "Σχετικά με εμάς.. " + "Σχετικά με εμάς…" Τι σκέφτεστε; Δημοσίευση Αποθήκευση @@ -101,7 +101,7 @@ Μεταφόρτωση εικόνας Μεταφόρτωση… Ο χρήστης δεν έχει ορίσει διεύθυνση LN για να λαμβάνει sats - "απαντήστε εδώ.. " + "απαντήστε εδώ…" Αντιγραφή του ID της δημοσίευσης στο πρόχειρο για κοινή χρήση στο Nostr Αντιγραφή του ID του Καναλιού (της δημοσίευσης) στο πρόχειρο Επεξεργασία των Μεταδεδομένων των Καναλιού @@ -133,7 +133,7 @@ "\"Δημόσιο Κλειδί\" (npub), Όνομα Χρήστη, Κείμενο" Εκκαθάριση Λογότυπο Εφαρμογής - \"Μυστικό Κλειδί\" (nsec).. ή \"Δημόσιο Κλειδί\" (npub).. + \"Μυστικό Κλειδί\" (nsec)…ή \"Δημόσιο Κλειδί\" (npub).. Εμφάνιση Κωδικού Απόκρυψη Κωδικού Μη έγκυρο κλειδί @@ -380,8 +380,6 @@ Η Αποσύνδεση διαγράφει όλες τις τοπικές πληροφορίες σας. Βεβαιωθείτε ότι έχετε Αντίγραφο Ασφαλείας από το \"Μυστικό Κλειδί\" σας για να αποφύγετε απώλεια του λογαριασμού σας. Θέλετε να συνεχίσετε? Ετικέτες που Ακολουθώ Διαμοιραστές - Ζωντανά - Κοινότητα Συνομιλίες Εγκεκριμένες Δημοσιεύσεις Αυτή η ομάδα δεν έχει περιγραφή ή κανόνες. Μιλήστε με τον ιδιοκτήτη για να προσθέσετε μία diff --git a/amethyst/src/main/res/values-eo-rUY/strings.xml b/amethyst/src/main/res/values-eo-rUY/strings.xml index 46053234e9..3fce4d2058 100644 --- a/amethyst/src/main/res/values-eo-rUY/strings.xml +++ b/amethyst/src/main/res/values-eo-rUY/strings.xml @@ -69,7 +69,7 @@ Mia Mojosa Grupo Bilda URL Priskribo - "Pri ni... " + "Pri ni…" Kion vi pensas? Afiŝi Konservi @@ -101,7 +101,7 @@ Alŝuti Bildon Alŝutante… Uzanto ne havas lightning-adreson por ricevi satojn - "respondi ĉi tie... " + "respondi ĉi tie.…" Kopias la ID de Noto al la tondujo por kunhavigi Kopii ID de Kanalo (Noto) al la tondujo Redaktas la Metadatumojn de Kanalo @@ -165,8 +165,8 @@ tradukita el al Montri en %1$s unua - Ĉiam traduki al - Neniam traduki el + Ĉiam traduki al %1$s + Neniam traduki el %1$s Adreso de Nostr neniam nun @@ -259,7 +259,7 @@ Minimumo de Zapoj Maksimumo de Zapoj Interkonsento - (0-100)% + (0–100)% Fermi post tagoj Kvanto de zapoj @@ -379,8 +379,6 @@ Elsaluti forigas ĉiujn viajn lokajn datumojn. Certigi ke vi sekurkopis vian privatan ŝlosilon por eviti perdi vian konton. Ĉu vi volas daŭrigi? Sekvitaj Etikedoj Plusendiloj - Vive - Komunumo Babilejoj Aprobitaj Afiŝoj Ĉi tiu grupo ne havas priskribon aŭ regulojn. Parolu kun la posedanto por aldoni diff --git a/amethyst/src/main/res/values-eo/strings.xml b/amethyst/src/main/res/values-eo/strings.xml index 46053234e9..eed86b9974 100644 --- a/amethyst/src/main/res/values-eo/strings.xml +++ b/amethyst/src/main/res/values-eo/strings.xml @@ -69,7 +69,7 @@ Mia Mojosa Grupo Bilda URL Priskribo - "Pri ni... " + "Pri ni…" Kion vi pensas? Afiŝi Konservi @@ -101,7 +101,7 @@ Alŝuti Bildon Alŝutante… Uzanto ne havas lightning-adreson por ricevi satojn - "respondi ĉi tie... " + "respondi ĉi tie… " Kopias la ID de Noto al la tondujo por kunhavigi Kopii ID de Kanalo (Noto) al la tondujo Redaktas la Metadatumojn de Kanalo @@ -165,8 +165,8 @@ tradukita el al Montri en %1$s unua - Ĉiam traduki al - Neniam traduki el + Ĉiam traduki al %1$s + Neniam traduki el %1$s Adreso de Nostr neniam nun @@ -259,7 +259,7 @@ Minimumo de Zapoj Maksimumo de Zapoj Interkonsento - (0-100)% + (0–100)% Fermi post tagoj Kvanto de zapoj @@ -379,8 +379,6 @@ Elsaluti forigas ĉiujn viajn lokajn datumojn. Certigi ke vi sekurkopis vian privatan ŝlosilon por eviti perdi vian konton. Ĉu vi volas daŭrigi? Sekvitaj Etikedoj Plusendiloj - Vive - Komunumo Babilejoj Aprobitaj Afiŝoj Ĉi tiu grupo ne havas priskribon aŭ regulojn. Parolu kun la posedanto por aldoni diff --git a/amethyst/src/main/res/values-es-rES/strings.xml b/amethyst/src/main/res/values-es-rES/strings.xml index 67d37df3ce..c0f9ae00b0 100644 --- a/amethyst/src/main/res/values-es-rES/strings.xml +++ b/amethyst/src/main/res/values-es-rES/strings.xml @@ -96,9 +96,9 @@ Mi grupo genial URL de imagen Descripción - "Sobre nosotros… " + "Sobre nosotros…" ¿Qué tienes en mente? - Escribir un mensaje... + Escribir un mensaje… Publicar Guardar Crear @@ -290,7 +290,6 @@ Dejar de seguir Seguir Eliminar de la galería - Elimina este contenido multimedia de la galería, aunque puedes volver a agregarlo luego. Solicitar eliminación Amethyst solicitará que se elimine su nota de los relays a los que está conectado actualmente. No hay garantía de que su nota se elimine permanentemente de esos relays, o de otros relays donde pueda almacenarse. Bloquear @@ -540,10 +539,7 @@ Al cerrar la sesión se borra toda tu información local. Asegúrate de tener una copia de seguridad de tus claves privadas para que no pierdas la cuenta. ¿Quieres continuar? Etiquetas seguidas Relés - Descubrimiento de notas Mercado - En vivo - Comunidad Chats Publicaciones aprobadas Este grupo no tiene descripción ni reglas. Habla con el propietario para agregar una. diff --git a/amethyst/src/main/res/values-es-rMX/strings.xml b/amethyst/src/main/res/values-es-rMX/strings.xml index cb49d6cf27..8498d38666 100644 --- a/amethyst/src/main/res/values-es-rMX/strings.xml +++ b/amethyst/src/main/res/values-es-rMX/strings.xml @@ -96,9 +96,9 @@ Mi grupo genial URL de imagen Descripción - "Quiénes somos..." + "Quiénes somos…" ¿Qué estás pensando? - Escribir un mensaje... + Escribir un mensaje… Publicar Guardar Crear @@ -290,7 +290,6 @@ Dejar de seguir Seguir Eliminar de la galería - Elimina este contenido multimedia de la galería, aunque puedes volver a agregarlo luego. Solicitar eliminación Amethyst solicitará que se elimine la nota de los relés con los que tienes conexión actualmente. No hay garantía de que la nota se elimine permanentemente de esos relés o de otros donde pueda estar guardada. Bloquear @@ -540,10 +539,7 @@ Al cerrar la sesión se borra toda tu información local. Asegúrate de tener una copia de seguridad de tus claves privadas para que no pierdas la cuenta. ¿Quieres continuar? Etiquetas seguidas Relés - Descubrimiento de notas Mercado - En vivo - Comunidad Chats Publicaciones aprobadas Este grupo no tiene descripción ni reglas. Habla con el propietario para agregar una. diff --git a/amethyst/src/main/res/values-es-rUS/strings.xml b/amethyst/src/main/res/values-es-rUS/strings.xml index 3ad324eec1..e286206332 100644 --- a/amethyst/src/main/res/values-es-rUS/strings.xml +++ b/amethyst/src/main/res/values-es-rUS/strings.xml @@ -96,9 +96,9 @@ Mi grupo genial URL de imagen Descripción - "Quiénes somos..." + "Quiénes somos…" ¿Qué estás pensando? - Escribir un mensaje... + Escribir un mensaje… Publicar Guardar Crear @@ -290,7 +290,6 @@ Dejar de seguir Seguir Eliminar de la galería - Elimina este contenido multimedia de la galería, aunque puedes volver a agregarlo luego. Solicitar eliminación Amethyst solicitará que se elimine la nota de los relés con los que tienes conexión actualmente. No hay garantía de que la nota se elimine permanentemente de esos relés o de otros donde pueda estar guardada. Bloquear @@ -540,10 +539,7 @@ Al cerrar la sesión se borra toda tu información local. Asegúrate de tener una copia de seguridad de tus claves privadas para que no pierdas la cuenta. ¿Quieres continuar? Etiquetas seguidas Relés - Descubrimiento de notas Mercado - En vivo - Comunidad Chats Publicaciones aprobadas Este grupo no tiene descripción ni reglas. Habla con el propietario para agregar una. diff --git a/amethyst/src/main/res/values-es/strings.xml b/amethyst/src/main/res/values-es/strings.xml index c2e99691f1..6a985205a4 100644 --- a/amethyst/src/main/res/values-es/strings.xml +++ b/amethyst/src/main/res/values-es/strings.xml @@ -1,5 +1,5 @@ - + Marcar los conocidos como leídos Marcar los nuevos como leídos Marcar todos como leídos @@ -13,7 +13,7 @@ Badge otorgado a Texto de nota copiado al portapapeles \@npub del autor copiado al portapapeles - ID de nota copiada (@note1) al portapapeles + ID de nota copiada (@note1) al portapapeles Seleccionar texto Apunta al código QR Mostrar el QR @@ -78,7 +78,7 @@ Mi grupo genial URL de imagen Descripción - "Sobre nosotros… " + "Sobre nosotros…" ¿Qué tienes en mente? Enviar Guardar diff --git a/amethyst/src/main/res/values-fa-rIR/strings.xml b/amethyst/src/main/res/values-fa-rIR/strings.xml index 71a721d723..7ee325eb60 100644 --- a/amethyst/src/main/res/values-fa-rIR/strings.xml +++ b/amethyst/src/main/res/values-fa-rIR/strings.xml @@ -81,6 +81,13 @@ !خیلی ممنون مبلغ به ساتوشی ارسال ساتوشی + شکلک ساز مخفی + یک شکلک به همراه پیام مخفی به یادداشت اضافه کنید + پیام مخفی به گیرنده + پیام پنهان من + پیشوند نمایان + 😎 + افزودن به یادداشت " %1$s : %2$s خطا در تبدیل" "%1$s پیش نمایش تصویر کارت برای" کانال جدید @@ -88,8 +95,9 @@ گروه باحال من آدرس تصویر توصیف - "درباره ما.. " + "درباره ما…" به چی فکر میکنی؟ + نوشتن یک پیام… ارسال ذخیره ساختن @@ -106,6 +114,7 @@ خبرنامه همگانی جستجو در خبرنامه افزودن رله + نام نمایش نام نام من استریچ باحالیان @@ -127,9 +136,9 @@ ویدئو در گالری گوشی ذخیره شد ویدئو ذخیره نشد بارگذاری تصویر - ...در حال بارگذاری + …در حال بارگذاری این کاربر آدرس لایتنینگ برای دریافت ساتوشی تنظیم نکرده است - "...اینجا پاسخ دهید " + "…اینجا پاسخ دهید " شناسه یادداشت را برای اشتراک گذاری در کلیپبورد ذخیره می کند ذخیره شناسه کانال در کلیپبورد متادیتای کانال را ذخیره می کند @@ -141,6 +150,7 @@ گفتگوها یادداشت ها پاسخ ها + مال شما گالری "دنبال شوندگان" "گزارش ها" @@ -195,6 +205,7 @@ با توصیف و تصویر نام گفتگو تغییر کرد به + نمایه جدید گفتگو: تصویر و تصویر به ترک کردن @@ -202,6 +213,13 @@ کانال ساخته شد "اطلاعات کانال تغییر کرد به" گفتگوی عمومی + فراداده گفتگوی عمومی + گفتگوهای عمومی در ناستر برای همه نمایان هستند + می توانند در آن شرکت کنند. آنها برای انجمن های باز درباره موضوعات به‌خصوص عالی هستند. + نظارت می تواند توسط حذف پست ها از رله ها انجام گیرد + رله ها + برای میزبانی این گروه ۱تا ۳ رله وارد کنید. + کلاینت های ناستر از این تنظیمات استفاده می کنند تا بدانند پیام ها را از کجا بارگیری کنند و به کجا بفرستند. یادداشت های دریافت شده بیرون کردن خودکار @@ -270,7 +288,6 @@ دنبال نکردن دنبال کردن حذف از گالری - حذف این رسانه از گالری، بعدا می توانید آن را بخوانید درخواست حذف آماتیست درخواست می کند که یادداشت شما از رله هایی که درحال حاضر به آن متصل هستید حذف شود. هیچ تضمینی نیست که یادداشت شما برای همیشه از آن رله ها یا از رله های دیگری که ممکن است در آنها ذخیره شده باشد حذف خواهد شد.. بلاک @@ -289,10 +306,10 @@ گزارش سواستفاده تمام گزارش ها بطور عمومی دیده می شوند - در صورت تمایل توضیحاتی به گزارش خود بیفزایید... + در صورت تمایل توضیحاتی به گزارش خود بیفزایید… توضیحات اضافه دلایل - دلیلی انتخاب کنید... + دلیلی انتخاب کنید… گزارش یادداشت بلاک و گزارش بلاک @@ -508,6 +525,7 @@ ارسال به کیف پول زپ باز کردن در کیف پول Cashu کپی کردن توکن + در یک اپلیکیشن دیگر باز کن آدرس لایتنینگ تنظیم نشده است توکن به کلیپبورد کپی شد زنده @@ -519,10 +537,7 @@ خروج همه اطلاعات محلی شما را پاک می کند. مطمئن شوید که کلید خصوصی خود را بکاپ گرفته و ذخیره کرده اید تا حساب کاربری تان را از دست ندهید. می خواهید ادامه دهید؟ برچسب های دنبال شده رله ها - اکتشاف یادداشت بازار - زنده - انجمن گپ یادداشت های تایید شده این گروه هیچ توصیف و قوانینی ندارد. با مالک گروه برای افزودن آن صحبت کنید. @@ -587,6 +602,8 @@ موضوع موضوع گفتگو "\@کاربر1, @کاربر2, @کاربر3" + نمی توان بارگذاری کرد + مقصد پیام را وارد کنید اعضای گروه توضیحات برای اعضا تعویض نام به خاطر اهداف جدید. @@ -681,7 +698,7 @@ فراخوانی URL از پاسخ %1$s یافت نشد خطا در تفسیر JSON از فراخوان صورتحساب آدرس لایتنینگ. تنظیمات لایتنینگ کاربر را بررسی کنید. خطا در تفسیر JSON از دریافت صورتحساب %1$s. تنظیمات لایتنینگ کاربر را بررسی کنید. - مبلغ نادرست صورتحساب (%1$s ساتوشی). می بایست %3$s ساتوشی باشد. + مبلغ نادرست صورتحساب (%1$s ساتوشی)از %2$s. می بایست %3$s ساتوشی باشد. نمی توان صورتحساب لایتنینگ را پیش از زپ زدن ارسال کرد. کیف پول لایتنینگی گیرنده خطای روبرو را داد: %1$s صورت حساب لایتنینگ ساخته نشد. پیغام از %1$s: %2$s نمی توان صورتحساب لایتنینگ را پیش از زپ زدن ارسال کرد. المنت pr در JSON بدست آمده یافت نشد. @@ -828,8 +845,8 @@ این نوع رله تمام پاسخ ها، نظرات، پسندها، و زپ های یادداشت های شما را دریافت می کند. این ها می توانند رله های پولی یا رایگان باشند. محدودیتی که گرداننده رله تعیین می کند ممکن است اعلان هایی را که دریافت می کنید محدود کند. این می تواند اثر خوب یا بد داشته باشد. مثلا اگر تحت حمله کامنت اسپم باشید، رله پولی می تواند این اسپم را فیلتر کند. بین ۱-۳ رله وارد کنید. رله های صندوق دریافت پیام خصوصی بین ۱-۳ رله به عنوان رله صندوق ورودی خصوصی وارد کنید. دیگران از این رله ها برای ارسال پیام خصوصی به شما استفاده خواهند کرد. رله های صندوق دریافت پیام خصوصی می بایست هر پیامی از هر کسی را بپذیرند، اما فقط بگذارند شما آن ها را بارگیری کنید. انتخاب های خوب شامل: - - inbox.nostr.wine (پولی) -- auth.nostr1.com (رایگان) + - inbox.nostr.wine (پولی) +- auth.nostr1.com (رایگان) - you.nostr1.com (رله پولی یا شخصی) رله های خصوصی خانه بین ۱-۳ رله وارد کنید برای ذخیره رویدادهایی که هیچ کس دیگر نمی بیند ، به عنوان مثال پیش نویس های شما و یا تنظیمات اپلیکیشن. در حالت ایده آل، این رله ها یا محلی هستند یا پیش از بارگیری محتوای کاربر به احراز هویت نیاز دارند. @@ -863,6 +880,7 @@ خلاصه تغییرات اصلاحات سریع… پذیرش پیشنهاد + ویديو را در قاب جدا باز کن بارگيری روشن کردن متن متن خاموش @@ -928,4 +946,5 @@ هیچ اپ تورنتی برای باز کردن و بارگیری فایل نصب نیست. لیستی را برای فیلتر خبرنامه انتخاب کنید با قفل کردن دستگاه از حساب کاربری خارج شو + پیام خصوصی diff --git a/amethyst/src/main/res/values-fa/strings.xml b/amethyst/src/main/res/values-fa/strings.xml index 71a721d723..0c2e5174b0 100644 --- a/amethyst/src/main/res/values-fa/strings.xml +++ b/amethyst/src/main/res/values-fa/strings.xml @@ -88,7 +88,7 @@ گروه باحال من آدرس تصویر توصیف - "درباره ما.. " + "درباره ما…" به چی فکر میکنی؟ ارسال ذخیره @@ -127,9 +127,9 @@ ویدئو در گالری گوشی ذخیره شد ویدئو ذخیره نشد بارگذاری تصویر - ...در حال بارگذاری + …در حال بارگذاری این کاربر آدرس لایتنینگ برای دریافت ساتوشی تنظیم نکرده است - "...اینجا پاسخ دهید " + "…اینجا پاسخ دهید " شناسه یادداشت را برای اشتراک گذاری در کلیپبورد ذخیره می کند ذخیره شناسه کانال در کلیپبورد متادیتای کانال را ذخیره می کند @@ -270,7 +270,6 @@ دنبال نکردن دنبال کردن حذف از گالری - حذف این رسانه از گالری، بعدا می توانید آن را بخوانید درخواست حذف آماتیست درخواست می کند که یادداشت شما از رله هایی که درحال حاضر به آن متصل هستید حذف شود. هیچ تضمینی نیست که یادداشت شما برای همیشه از آن رله ها یا از رله های دیگری که ممکن است در آنها ذخیره شده باشد حذف خواهد شد.. بلاک @@ -289,10 +288,10 @@ گزارش سواستفاده تمام گزارش ها بطور عمومی دیده می شوند - در صورت تمایل توضیحاتی به گزارش خود بیفزایید... + در صورت تمایل توضیحاتی به گزارش خود بیفزایید… توضیحات اضافه دلایل - دلیلی انتخاب کنید... + دلیلی انتخاب کنید… گزارش یادداشت بلاک و گزارش بلاک @@ -519,10 +518,7 @@ خروج همه اطلاعات محلی شما را پاک می کند. مطمئن شوید که کلید خصوصی خود را بکاپ گرفته و ذخیره کرده اید تا حساب کاربری تان را از دست ندهید. می خواهید ادامه دهید؟ برچسب های دنبال شده رله ها - اکتشاف یادداشت بازار - زنده - انجمن گپ یادداشت های تایید شده این گروه هیچ توصیف و قوانینی ندارد. با مالک گروه برای افزودن آن صحبت کنید. @@ -681,7 +677,7 @@ فراخوانی URL از پاسخ %1$s یافت نشد خطا در تفسیر JSON از فراخوان صورتحساب آدرس لایتنینگ. تنظیمات لایتنینگ کاربر را بررسی کنید. خطا در تفسیر JSON از دریافت صورتحساب %1$s. تنظیمات لایتنینگ کاربر را بررسی کنید. - مبلغ نادرست صورتحساب (%1$s ساتوشی). می بایست %3$s ساتوشی باشد. + مبلغ نادرست صورتحساب (%1$s ساتوشی)از %2$s. می بایست %3$s ساتوشی باشد. نمی توان صورتحساب لایتنینگ را پیش از زپ زدن ارسال کرد. کیف پول لایتنینگی گیرنده خطای روبرو را داد: %1$s صورت حساب لایتنینگ ساخته نشد. پیغام از %1$s: %2$s نمی توان صورتحساب لایتنینگ را پیش از زپ زدن ارسال کرد. المنت pr در JSON بدست آمده یافت نشد. @@ -828,8 +824,8 @@ این نوع رله تمام پاسخ ها، نظرات، پسندها، و زپ های یادداشت های شما را دریافت می کند. این ها می توانند رله های پولی یا رایگان باشند. محدودیتی که گرداننده رله تعیین می کند ممکن است اعلان هایی را که دریافت می کنید محدود کند. این می تواند اثر خوب یا بد داشته باشد. مثلا اگر تحت حمله کامنت اسپم باشید، رله پولی می تواند این اسپم را فیلتر کند. بین ۱-۳ رله وارد کنید. رله های صندوق دریافت پیام خصوصی بین ۱-۳ رله به عنوان رله صندوق ورودی خصوصی وارد کنید. دیگران از این رله ها برای ارسال پیام خصوصی به شما استفاده خواهند کرد. رله های صندوق دریافت پیام خصوصی می بایست هر پیامی از هر کسی را بپذیرند، اما فقط بگذارند شما آن ها را بارگیری کنید. انتخاب های خوب شامل: - - inbox.nostr.wine (پولی) -- auth.nostr1.com (رایگان) + - inbox.nostr.wine (پولی) +- auth.nostr1.com (رایگان) - you.nostr1.com (رله پولی یا شخصی) رله های خصوصی خانه بین ۱-۳ رله وارد کنید برای ذخیره رویدادهایی که هیچ کس دیگر نمی بیند ، به عنوان مثال پیش نویس های شما و یا تنظیمات اپلیکیشن. در حالت ایده آل، این رله ها یا محلی هستند یا پیش از بارگیری محتوای کاربر به احراز هویت نیاز دارند. diff --git a/amethyst/src/main/res/values-fi-rFI/strings.xml b/amethyst/src/main/res/values-fi-rFI/strings.xml index cf323c4926..72ed458278 100644 --- a/amethyst/src/main/res/values-fi-rFI/strings.xml +++ b/amethyst/src/main/res/values-fi-rFI/strings.xml @@ -71,7 +71,7 @@ Oma upea ryhmäni Kuvan URL Kuvaus - "Tietoa meistä.. " + "Tietoa meistä…" Mitä mielessä? Lähetä Tallenna @@ -233,10 +233,10 @@ Ilmoita väärinkäytöstä Kaikki ilmoitukset ovat julkisesti nähtävillä. - Voit antaa lisäkontekstia ilmoituksellesi valinnaisesti... + Voit antaa lisäkontekstia ilmoituksellesi valinnaisesti… Lisäkonteksti Syy - Valitse syy... + Valitse syy… Lähetä ilmoitus Estä ja ilmoita Estä @@ -258,7 +258,7 @@ Luo äänestys Vaaditut kohdat: Zap-vastaanottajat - Pääasiallinen äänestyksen kuvaus... + Pääasiallinen äänestyksen kuvaus… Vaihtoehto %s Vaihtoehdon kuvaus Valinnaiset kohdat: @@ -391,7 +391,6 @@ Seuratut tagit Releet Markkinapaikka - Yhteisö Keskustelut Hyväksytyt viestit Tällä ryhmällä ei ole kuvausta tai sääntöjä. Keskustele omistajan kanssa sen lisäämiseksi diff --git a/amethyst/src/main/res/values-fr-rFR/strings.xml b/amethyst/src/main/res/values-fr-rFR/strings.xml index a54e04ff7c..9487c1c913 100644 --- a/amethyst/src/main/res/values-fr-rFR/strings.xml +++ b/amethyst/src/main/res/values-fr-rFR/strings.xml @@ -96,9 +96,9 @@ Mon groupe spectaculaire Url de l\'image Description - "à propos" + "à propos…" Quoi de neuf ? - Rédiger un message... + Rédiger un message… Envoyer Sauvegarder Créer @@ -290,7 +290,6 @@ Ne plus suivre Suivre Supprimer de la galerie - Retirer ce média de votre galerie, vous pourrez le réajouter plus tard Demande de Suppression Amethyst demandera que votre note soit supprimée des relais auxquels vous êtes actuellement connecté. Il n\'y a aucune garantie que votre note sera définitivement supprimée de ces relais, ou d\'autres relais où elle peut être stockée. Bloquer @@ -540,10 +539,7 @@ Se déconnecter supprime toutes vos informations locales. Assurez-vous d\'avoir vos clés privées sauvegardées pour éviter de perdre votre compte. Voulez-vous continuer ? Tags suivis Relais - Découverte de notes Place de Marché - Direct - Communauté Salons Messages approuvés Ce groupe n\'a pas de description ou de règles. Parlez au propriétaire pour en ajouter une @@ -830,7 +826,7 @@ Ce paramètre informe tout le monde les relais à utiliser pour vous envoyer des messages. Sans eux, vous risquez de manquer certains messages. Les bonnes options sont:\n - inbox.nostr.wine (payant)\n - auth.nostr1.com (gratuit)\n - you.nostr1.com (relais personnels - payant) De bonnes options sont:\n - auth.nostr1.com (gratuit)\n - inbox.nostr.wine (payant)\n - relay.0xchat.com (gratuit) - Insérez entre 1-3 relais pour utiliser de boîte de réception privée. Les relais de boîte de réception MP doivent accepter les messages de tout le monde, mais ne vous permet que de les télécharger. + Insérez entre 1–3 relais pour utiliser de boîte de réception privée. Les relais de boîte de réception MP doivent accepter les messages de tout le monde, mais ne vous permet que de les télécharger. Configurer maintenant Relais de recherche Configurez vos relais de recherche @@ -875,7 +871,7 @@ Modifier le Message Proposition pour améliorer votre publication Récapitulatif des modifications - Corrections rapides ... + Corrections rapides … Accepter la Suggestion Lancer la vidéo dans une popup Télécharger diff --git a/amethyst/src/main/res/values-fr/strings.xml b/amethyst/src/main/res/values-fr/strings.xml index 404809453c..bb35fd681d 100644 --- a/amethyst/src/main/res/values-fr/strings.xml +++ b/amethyst/src/main/res/values-fr/strings.xml @@ -96,8 +96,9 @@ Mon groupe spectaculaire Url de l\'image Description - "à propos" + "à propos…" Quoi de neuf ? + Rédiger un message… Envoyer Sauvegarder Créer @@ -281,7 +282,6 @@ Ne plus suivre Suivre Supprimer de la galerie - Retirer ce média de votre galerie, vous pourrez le réajouter plus tard Demande de Suppression Amethyst demandera que votre note soit supprimée des relais auxquels vous êtes actuellement connecté. Il n\'y a aucune garantie que votre note sera définitivement supprimée de ces relais, ou d\'autres relais où elle peut être stockée. Bloquer @@ -530,10 +530,7 @@ Se déconnecter supprime toutes vos informations locales. Assurez-vous d\'avoir vos clés privées sauvegardées pour éviter de perdre votre compte. Voulez-vous continuer ? Tags suivis Relais - Découverte de notes Place de Marché - Direct - Communauté Salons Messages approuvés Ce groupe n\'a pas de description ou de règles. Parlez au propriétaire pour en ajouter une @@ -818,7 +815,7 @@ Ce paramètre informe tout le monde les relais à utiliser pour vous envoyer des messages. Sans eux, vous risquez de manquer certains messages. Les bonnes options sont:\n - inbox.nostr.wine (payant)\n - auth.nostr1.com (gratuit)\n - you.nostr1.com (relais personnels - payant) De bonnes options sont:\n - auth.nostr1.com (gratuit)\n - inbox.nostr.wine (payant)\n - relay.0xchat.com (gratuit) - Insérez entre 1-3 relais pour utiliser de boîte de réception privée. Les relais de boîte de réception MP doivent accepter les messages de tout le monde, mais ne vous permet que de les télécharger. + Insérez entre 1–3 relais pour utiliser de boîte de réception privée. Les relais de boîte de réception MP doivent accepter les messages de tout le monde, mais ne vous permet que de les télécharger. Configurer maintenant Relais de recherche Configurez vos relais de recherche @@ -863,7 +860,7 @@ Modifier le Message Proposition pour améliorer votre publication Récapitulatif des modifications - Corrections rapides ... + Corrections rapides … Accepter la Suggestion Télécharger Paroles activées diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index e49c5d1741..769da570cf 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -71,6 +71,8 @@ अपक्रम संदेश परखने में अपक्रम " का अनुचरण" " अनुचर" + "%1$s का अनुचरण" + "%1$s अनुचर" परिचय सुरक्षा छलनियाँ निर्गमनांकन @@ -96,9 +98,9 @@ मेरा बढिया झुण्ड चित्र जालपता विवरण - "हमारा परिचय.. " + "हमारा परिचय…" आपके मन में क्या है? - सन्देश लिखें ... + सन्देश लिखें … पत्र प्रकाशन अभिलेखन करें बनाएँ @@ -155,6 +157,7 @@ चित्रालय "का अनुचरण" "सूचनाएँ" + "%1$s सूचनाएँ" अधिक विकल्प " पुनःप्रसारक" जालस्थान @@ -213,6 +216,13 @@ अनुचरण ना करें प्रणाली बनायी गयी "प्रणाली जानकारी परिवर्तित की गयी" + नश्वर चर्चा + पुनःप्रसारक चर्चा + पुनःप्रसारक चर्चाएँ + पुनःप्रसारक चर्चाएँ आवासी पुनःप्रसारक द्वारा नियन्त्रित चर्चा समुदाएँ हैं। + वे नोस्टर पर सभी के लिए दृश्य हैं तथा उनमें सभी भाग ले सकते हैं। + वे उत्कृष्ट हैं खुले समुदायों के लिए विशिष्ट विषयों पर। इनमें से कुछ समुदाएँ अस्थायी हैं। + तथा इसीलिए समय के साथ चर्चा सन्देश अदृश्य हो जाते हैं सार्वजनिक चर्चा सार्वजनिक चर्चा उपतथ्य सार्वजनिक चर्चाएँ सबके लिए दृश्यमान हैं नोस्टर पर तथा सभी @@ -290,7 +300,7 @@ अनुचरण ना करें अनुचरण करें चित्रालय से मिटाएँ - इस अभिलेख को चित्रालय से हटाएँ। तत्पश्चात कभी भी आप इसे पुनः जोड सकते हैं + इस अभिलेख को आपके चित्रालय से हटाएँ। हटाने की याचना अमेथिस्ट अनुरोध करेगा कि आपका टीका मिटा दिया जाए उन पुनःप्रसारकों से जिनके साथ आप अब जुडे हुए हैं। कोई आश्वासन नहीं कि आपका टीका सर्वदा के लिए मिटा दिया जाएगा उन पुनःप्रसारकों से, अथवा अन्य पुनःप्रसारकों में से जहाँ यह रखा गया हो। बाधित करें @@ -486,9 +496,18 @@ संवेदनशील विषयवस्तु सर्वदा छिपाएँ संवेदनशील विषयवस्तु सर्वदा दिखाएँ विषयवस्तु चेतावनियाँ सर्वदा दिखाएँ + छिपाएँ + दिखाएँ + चेतावनी दें अनुशंसित : अपरिचित जन के भेजे गये कचरालेखों को छलनी द्वारा हटाएँ चेतावनी दें जब पत्र सूचित किये गये हों आपके द्वारा अनुचरित व्यक्तियों से + कचरालेख छलनी + अज्ञात लोगों से पत्र छिपाएँ जो निरन्तर 5 अथवा अधिक बार यथावत समान थे + सूचनाएँ प्राप्त होने पर चेतावनी दें + चेतावनी सन्देश दिखाता है जब प्रकाशित पत्र 5 अथवा अधिक बार सूचित हो आपके द्वारा अनुचरित लेखाओं से + संवेदनशील विषयवस्तु दिखाएँ + चेतावनी सन्देश दिखाता है जब प्रकाशित पत्र के लेखक ने उसे संवेदनशील चिह्नित किया नया प्रतिक्रिया चिह्न इस उपयोगकर्ता के लिए कोई प्रतिक्रिया प्रकार पूर्व चयनित नहीं। हृदयचिह्न घुण्डी पर दीर्घतः दबाएँ परिवर्तन करने के लिए ज्सापोपार्जन योजना @@ -540,10 +559,12 @@ निर्गमनांकन करने पर आपकी सारी स्थानीय जानकारी मिट जाएगी। सुनिश्चित करें कि आपके निजी कुंचिकाएँ सुरक्षित रखें हैं अपनी लेखा नहीं खोना चाहते हैं तो। क्या आप आगे बढना चाहते हैं? अनुचरित विषयसूचक पुनःप्रसारक - लेख आविष्करण + अनुचरण पोटलियाँ + पठितव्य + सूचनावली विधियाँ पण्यक्षेत्र - तत्क्षणप्रसार - समुदाय + तत्क्षणप्रसार + समुदाय चर्चाएँ अनुमति प्राप्त पत्र इस झुण्ड का कोई विवरण नहीं नियम नहीं। इसके अधिपति से बात करें इनहें जोडने के लिए @@ -551,6 +572,7 @@ संवेदनशील विषयवस्तु इसे दिखाने से पूर्व संवेदनशील विषयवस्तु चेतावनी जोडता है क्रमक आद्यताएँ + उपयोगकर्ता आद्यताएँ स्थापना विकल्प सर्वदा केवल वै॰फै॰ @@ -595,6 +617,8 @@ आपका भूगोलिक स्थान विभेदक जोडता है पत्र में। जनता जान जाएगी कि आप वर्तमान स्थान से ५ कि॰मे॰ (३ मी॰) की दूरी के अन्दर हैं स्थल विशेष पत्र स्थल के अनुचर ही देखेंगे। आपके सामान्य अनुचर नहीं देखेंगे। + विषयसूचक विशेष पत्र + केवल विषयसूचक के अनुचर इसे देखेंगे। आपके सामान्य अनुचर नहीं देखेंगे। स्थान प्राप्त किया जा रहा है स्थान प्राप्त करने की अनुमति नहीं आपके विषयवस्तु दिखाने से पूर्व संवेदनशील विषयवस्तु चेतावनी जोडता है। यह आदर्श है किसी कार्यालय अनुचित विषयवस्तु के लिए अथवा जो कुछ लोगों के लिए आपत्तिजनक अथवा व्याकुल करनेवाला लग सकता है @@ -603,6 +627,7 @@ सक्रिय करें सार्वजनिक नया निजी अथवा सार्वजनिक झुण्ड + पुनःप्रसारक निजी के लिए विषय @@ -795,6 +820,8 @@ नया पत्र प्रकाशन नये छोटे : चित्र अथवा चलचित्र नया सामुदायिक टीका + नया उत्पाद + नया स्थान विशेष पत्र इस पत्र प्रकाशन के सभी प्रतिक्रियाओं को खोलें इस पत्र प्रकाशन के सभी प्रतिक्रियाओं को अवरोधित करें उत्तर @@ -944,4 +971,10 @@ सूचनावली छानने के लिए सूची चुनें यन्त्र ताला लगने पर निर्गमनांकन करें निजी सन्देश + चर्चा पुनःप्रसारक + वह पुनःप्रसारक जिससे इस चर्चा के सभी उपयोगकर्ता जुडते हैं + चित्र बाँटें… + विषयसूचक खोज : #%1$s + अनुवाद ना करें + यहाँ प्रस्तुत भाषाओं का अनुवाद नहीं होगा। भाषा चयन करें हटाने के लिए जिससे उसका अनुवाद पुनः होने लगेगा। diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 7ff89b8797..c87d142d55 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -51,6 +51,13 @@ Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy ki tudja követni az embereket, akiket követ Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy egy szót vagy mondat el tudjon rejteni Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy egy szót vagy mondat meg tudjon jeleníteni + Ön nyilvános kulcsot használ, és a nyilvános kulcsok csak olvashatóak. A beállítások módosításához jelentkezzen be egy privát kulccsal + Ön nyilvános kulcsot használ, és a nyilvános kulcsok csak olvashatóak. Jelentkezzen be egy privát kulccsal, hogy tudjon tartalmakat feltölteni + Ön nyilvános kulcsot használ, és a nyilvános kulcsok csak olvashatóak. Jelentkezzen be egy privát kulccsal, hogy aláírhassa az eseményeket + Jogosulatlan visszafejtés + Az aláíró nem engedélyezte a művelet végrehajtásához szükséges visszafejtést. Aktiválja az NIP-44 visszafejtést az aláíró alkalmazásban, és próbálja meg újra + Nem található az aláíró + Az aláíró alkalmazás el lett távolítva? Ellenőrizze, hogy az aláíró telepítve van-e és rendelkezik-e ezzel a fiókkal. Jelentkezzen ki és jelentkezzen be újra, ha az aláíró alkalmazás megváltozott. Zap Megtekintések száma Megtolás @@ -71,6 +78,8 @@ Hiba a hibaüzenet elemzésekor " Követett" " Követő" + "%1$s követett" + "%1$s követő" Profil Biztonsági szűrők Kijelentkezés @@ -96,6 +105,7 @@ A nagyszerű csoportom Kép webcíme Leírás + Nem található leírás "Névjegy… " Mi jár a fejében? Üzenet írása… @@ -137,6 +147,10 @@ Videó elmentve a videógalériába Nem sikerült elmenteni a videót Kép feltöltése + Kép készítése + Hangüzenet rögzítése + Hangüzenet rögzítése + Hangüzenet rögzítéséhez kattintson és tartsa lenyomva a gombot Feltöltés… A felhasználó nem rendelkezik a satoshik fogadásához beállított lightning-címmel "Válasz írása… " @@ -155,6 +169,7 @@ Galéria "Követett" "Bejelentés" + "%1$s jelentés" További beállítások " Átjátszó" Weboldal @@ -213,14 +228,23 @@ Követés megszüntetése Csatorna létrehozva "A csatornainformáció a következőre módosult:" + Eltűnő csevegés + Átjátszócsevegés + Átjátszócsevegések + Az átjátszócsevegések olyan csevegőcsoportok, amelyeket az otthoni átjátszó irányít. + Ezek mindenki számára láthatóak a Nostr-on, és bárki részt vehet rajtuk. + Remekül alkalmasak nyitott közösségek kialakítására bizonyos témák körül. Néhány ilyen csoport + rövid élettartamú, ezért a csevegési üzenetek idővel eltűnnek Nyilvános csevegés Nyilvános csevegés metaadatai A nyilvános csevegések mindenki számára láthatóak a Nostr-on, és bárki részt vehet bennük. Ezek kiválóan alkalmasak bizonyos témák köré szerveződő nyílt közösségek számára. A moderálás az átjátszókon lévő hozzászólások törlésével szabályozható Átjátszók - Adjon hozzá 1-3 olyan átjátszót, amelyek kiszolgálják ezt a csoportot. + Adjon hozzá 1–3 olyan átjátszót, amelyek kiszolgálják ezt a csoportot. A Nostr-kliensek ezt a beállítást használják arra, hogy tudják, honnan töltsék le az üzeneteket, és hová küldjék az üzeneteket. + Fizetős átjátszó + Tor kényszerítése kapcsolódáskor fogadott bejegyzések Eltávolítás Automatikus @@ -265,6 +289,7 @@ Hiba "Létrehozta: %1$s" "Kitűző %1$s számára" + Kitűző képe Ön egy új kitűzőt kapott Kitűzőben részesült Bejegyzés szövege a vágólapra másolva @@ -290,7 +315,7 @@ Követés megszüntetése Követés Törlés a galériából - Távolítsa el ezt a médiát a galériából, később újra hozzáadhatja + Távolítsa el ezt a médiát a galériából. Törlés kérése Az Amethyst kérni fogja, hogy a bejegyzését töröljék azokról az átjátszókról, amelyekhez jelenleg csatlakozik. Nem garantálható, hogy a bejegyzése véglegesen törlődik ezekről az átjátszókról, vagy más átjátszókról, ahol esetleg tárolva van. Letiltás @@ -412,6 +437,7 @@ Nem Követési lista Követettek bejegyzései + Követés proxyn keresztül Közelben lévők bejegyzései Globális Némítottak bejegyzései @@ -486,9 +512,18 @@ Az érzékeny tartalmat mindig rejtse el Az érzékeny tartalmat mindig jelenítse meg A tartalomra vonatkozó figyelmeztetéseket mindig jelenítse meg + Elrejtés + Megjelenítés + Figyelmeztetés Ajánlott alkalmazások: Az idegenektől érkező kéretlen tartalmak szűrése Figyelmeztessen amikor a bejegyzések jelentve vannak azok által akiket követek + Kéretlen tartalomszűrő + Elrejti az ismeretlen felhasználók hozzászólásait, amelyek pontosan ugyanazok voltak 5 vagy több alkalommal + Figyelmeztetés a jelentésekre + Figyelmeztető üzenet jelenik meg, ha a bejegyzéseket az Ön által követett felhasználók 5 vagy annál többször jelentették + Érzékeny tartalom megjelenítése + Figyelmeztető üzenet jelenik meg, ha a bejegyzés szerzője érzékenynek jelölte a hozzászólást Új reakció-szimbólum A felhasználó számára nincsenek előre kiválasztott reakciótípusok. Hosszan nyomja meg a szív gombot a módosításhoz Zap-gyűjtés @@ -540,10 +575,12 @@ A kijelentkezéssel törlődik az összes helyben tárolt adat. Győződjön meg arról, hogy a privát kulcsokról biztonsági mentést készített, hogy elkerülje fiókja elvesztését. Szeretné folytatni? Követett címke Átjátszók - Bejegyzések felfedezése + Követett csomagok + Olvasmányok + Hírforrás-algoritmus Piac - Élő - Közösség + Élő közvetítések + Közösségek Csevegések Elfogadott bejegyzések Ennek a csoportnak nincs leírása vagy szabályai. Kérje meg a tulajdonosát, hogy adjon hozzá egyet @@ -551,6 +588,7 @@ Érzékeny tartalom Érzékeny tartalom-figyelmeztetés hozzáadása a tartalom megjelenítése előtt Alkalmazás-beállítások + Felhasználói beállítások Beállítások Mindig Csak Wi-Fi-n @@ -595,6 +633,8 @@ Hozzáadja a helyének geohash-sét a bejegyzéséhez. A nyilvánosság tudni fogja, hogy a jelenlegi helytől 5 km-en (3 mi) belül tertózkodik Helyszín-alapú bejegyzés Csak a helyszín követői láthatják. Az általános követők nem fogják látni. + Hashtag-exkluzív bejegyzés + Csak a hashtag követői fogják látni, de az Ön általános követői viszont nem. Helyszín betöltése… A helyszín-meghatározás nincs engedélyezve Hozzáadja az érzékeny tartalomra vonatkozó figyelmeztetést a tartalom megjelenítése előtt. Ez ideális bármilyen NSFW tartalom vagy olyan tartalom esetén, amelyet egyesek sértőnek vagy zavarónak találhatnak @@ -603,6 +643,7 @@ Aktiválás Nyílvános Új nyilvános vagy privát csoport + Átjátszó Privát Neki: Tárgy @@ -668,6 +709,7 @@ Pénztárca: %1$s Hiba az aláíró-alkalmazás megnyitásakor Az aláíró alkalmazás nem található. Ellenőrizze, hogy az alkalmazás nincs-e eltávolítva + Aláírási kérés elutasítva Aláíró-alkalmazás elutasítva Győződjön meg arról, hogy ezt a tranzakciót az aláíró-alkalmazás hitelesítette-e Nem található pénztárca a Lighning-számla kifizetéséhez (Hiba: %1$s). A Zap-ek használatához telepítsen egy Lightning-pénztárcát @@ -795,6 +837,8 @@ Új bejegyzés Új rövidek: képek vagy videók Új közösségi bejegyzés + Új termék + Új hely-exkluzív bejegyzés A bejegyzésre adott összes reakció kibontása A bejegyzésre adott összes reakció összecsukása Válasz @@ -849,12 +893,29 @@ Adjon hozzá 1–3 átjátszót, hogy olyan eseményeket tároljanak, amelyeket senki más nem láthat, például a piszkozatait és/vagy az alkalmazásbeállításait. Ideális esetben ezek az átjátszók vagy helyi szintűek, vagy hitelesítést igényelnek az egyes felhasználói tartalmak letöltése előtt. Általános átjátszók Az Amethyst ezeket az átjátszókat a bejegyzések letöltésére használja. + Kapcsolódott átjátszók + Jelenlegi használatban lévő átjátszók listája Ajánlott átjátszók Adja hozzá a következő átjátszókat az általános átjátszók listájához, hogy megkapja a felsorolt felhasználók hozzászólásait. Keresési átjátszók A tartalom vagy felhasználók keresésekor használandó átjátszók listája. A címkézés és a keresés nem fog működni, ha nem áll rendelkezésre semmilyen beállítás. Győződjön meg arról, hogy alkalmazzák a NIP-50-et. Helyi átjátszók A készüléken futó átjátszók listája. + Megbízható átjátszók + Megbízható átjátszók + Az átjátszóknak, amelyekben megbízik, nincs szükségük Tor kapcsolatra a következőhöz: + Proxyzott átjátszók + Proxyzott átjátszók + Összesítő átjátszók, amelyeket az alkalmazásnak használnia kell a hírfolyamok letöltéséhez, például filter.nostr.wine. Ez helyettesíti a kimeneti modellt, és az alkalmazás csak a listákban szereplő átjátszókhoz fog csatlakozni. + Műsorszóró átjátszók + Műsorszóró átjátszók + Olyan átjátszók, amelyek arra specializálódtak, hogy továbbítsák a bejegyzéseket az összes többi átjátszónak, mint például a sendit.nosflare.com. Az Amethyst hozzáadja ezt az átjátszót az összes új eseményhez, amelyet Ön készített + Indexelő átjátszók + Indexelő átjátszók + Olyan átjátszók, amelyek mindenki metaadatainak és átjátszólistáinak tárolására specializálódtak, mint például a purplepag.es. Az Amethyst ezeket az átjátszókat használja arra, hogy megtalálja azokat a felhasználókat, akik nem szerepelnek az Ön listáin. + Letiltott átjátszók + Letiltott átjátszók + Az Amethyst soha nem fog csatlakozni ezekhez az átjátszókhoz Zap a fejlesztőknek! Az Ön adománya segít nekünk abban, hogy változtassunk a dolgokon. Minden satoshi számít! Adományozás most @@ -944,4 +1005,11 @@ Lista kiválasztása a hírfolyam szűréséhez Kijelentkeztetés az eszköz zárolása esetén Privát üzenet + Nyílvános üzenet + Csevegési átjátszó + Az átjátszó, amelyhez a csevegés összes felhasználója csatlakozik + Kép megosztása… + Hashtag keresése: #%1$s + Innentől NE fordítsa le + Az itt látható nyelvek nem lesznek lefordítva. Az eltávolításához és az újbóli fordításhoz válasszon ki egy nyelvet. diff --git a/amethyst/src/main/res/values-hu/strings.xml b/amethyst/src/main/res/values-hu/strings.xml index 35fe3141a9..039cf9427c 100644 --- a/amethyst/src/main/res/values-hu/strings.xml +++ b/amethyst/src/main/res/values-hu/strings.xml @@ -217,7 +217,7 @@ részt vehet bennük. Ezek kiválóan alkalmasak bizonyos témák köré szerveződő nyílt közösségek számára. A moderálás az átjátszókon lévő hozzászólások törlésével szabályozható Átjátszók - Adjon hozzá 1-3 olyan átjátszót, amelyek kiszolgálják ezt a csoportot. + Adjon hozzá 1–3 olyan átjátszót, amelyek kiszolgálják ezt a csoportot. A Nostr-kliensek ezt a beállítást használják arra, hogy tudják, honnan töltsék le az üzeneteket, és hová küldjék az üzeneteket. fogadott bejegyzések Eltávolítás @@ -288,7 +288,6 @@ Követés megszüntetése Követés Törlés a galériából - Távolítsa el ezt a médiát a galériából, később újra hozzáadhatja Törlés kérése Az Amethyst kérni fogja, hogy a bejegyzését töröljék azokról az átjátszókról, amelyekhez jelenleg csatlakozik. Nem garantálható, hogy a bejegyzése véglegesen törlődik ezekről az átjátszókról, vagy más átjátszókról, ahol esetleg tárolva van. Letiltás @@ -537,10 +536,7 @@ A kijelentkezéssel törlődik az összes helyben tárolt adat. Győződjön meg arról, hogy a privát kulcsokról biztonsági mentést készített, hogy elkerülje fiókja elvesztését. Szeretné folytatni? Követett címke Átjátszók - Bejegyzések felfedezése Piac - Élő - Közösség Csevegések Elfogadott bejegyzések Ennek a csoportnak nincs leírása vagy szabályai. Kérje meg a tulajdonosát, hogy adjon hozzá egyet diff --git a/amethyst/src/main/res/values-in-rID/strings.xml b/amethyst/src/main/res/values-in-rID/strings.xml index d0c1d0a400..422033638a 100644 --- a/amethyst/src/main/res/values-in-rID/strings.xml +++ b/amethyst/src/main/res/values-in-rID/strings.xml @@ -69,7 +69,7 @@ Groupku Luar Biasa Url gambar Deskripsi - "Tentang kami.. " + "Tentang kami…" Apa yang ada dalam pikiranmu? Posting Simpan @@ -260,7 +260,7 @@ Minimum Zap Maximum Zap Konsensus - (0-100)% + (0–100)% Tutup setelah hari Tidak dapat memilih @@ -377,8 +377,6 @@ Keluar akan menghapus semua informasi yang disimpan secara lokal. Pastikan kunci pribadi Anda telah simpan untuk menghindari kehilangan akun Anda. Apakah Anda ingin melanjutkan? Tags yang Diikuti - Langsung - Komunitas Obrolan Post yang disetujui Grup ini tidak memiliki deskripsi atau peraturan. diff --git a/amethyst/src/main/res/values-in/strings.xml b/amethyst/src/main/res/values-in/strings.xml index edcb257e44..00447eb60f 100644 --- a/amethyst/src/main/res/values-in/strings.xml +++ b/amethyst/src/main/res/values-in/strings.xml @@ -69,7 +69,7 @@ Grupku yang Hebat Alamat Url Gambar Deskripsi - "Tentang kami.. " + "Tentang kami…" Apa yang anda pikirkan? Kirim Simpan @@ -375,8 +375,6 @@ Keluar akan menghapus semua informasi lokal Anda. Pastikan kunci pribadi Anda dicadangkan untuk menghindari kehilangan akun Anda. Apakah Anda ingin melanjutkan? Tags yg diikuti Relai - Siaran Langsung - Komunitas Percakapan Kiriman yg disetujui Grup ini tidak memiliki deskripsi atau aturan. Bicaralah dengan pemiliknya untuk menambahkannya diff --git a/amethyst/src/main/res/values-it-rIT/strings.xml b/amethyst/src/main/res/values-it-rIT/strings.xml index 7310c1023a..b79e7df3ce 100644 --- a/amethyst/src/main/res/values-it-rIT/strings.xml +++ b/amethyst/src/main/res/values-it-rIT/strings.xml @@ -68,7 +68,7 @@ Il mio fantastico gruppo Url foto Descrizione - "Su di noi... " + "Su di noi…" A cosa stai pensando? Salva Crea @@ -361,8 +361,6 @@ La disconnessione elimina tutte le tue informazioni locali. Assicurati di avere le tue chiavi private salvate per evitare di perdere il tuo account. Vuoi continuare? Tag Seguiti Relè - In diretta - Comunità Chat Post approvati Questo gruppo non ha una descrizione o regole. Parla con il proprietario per aggiungerne una diff --git a/amethyst/src/main/res/values-ja-rJP/strings.xml b/amethyst/src/main/res/values-ja-rJP/strings.xml index ed0a8fe01b..21a5107d2c 100644 --- a/amethyst/src/main/res/values-ja-rJP/strings.xml +++ b/amethyst/src/main/res/values-ja-rJP/strings.xml @@ -370,8 +370,6 @@ ログアウトするとローカル情報はすべて削除されます。アカウントを紛失しないよう、秘密鍵のバックアップがあることを確認してください。続行しますか? フォロー済みタグ リレー - ライブ - コミュニティ チャット 承認済みの投稿 このグループには説明またはルールがありません。追加するには管理者に相談してください diff --git a/amethyst/src/main/res/values-ja/strings.xml b/amethyst/src/main/res/values-ja/strings.xml index ed0a8fe01b..21a5107d2c 100644 --- a/amethyst/src/main/res/values-ja/strings.xml +++ b/amethyst/src/main/res/values-ja/strings.xml @@ -370,8 +370,6 @@ ログアウトするとローカル情報はすべて削除されます。アカウントを紛失しないよう、秘密鍵のバックアップがあることを確認してください。続行しますか? フォロー済みタグ リレー - ライブ - コミュニティ チャット 承認済みの投稿 このグループには説明またはルールがありません。追加するには管理者に相談してください diff --git a/amethyst/src/main/res/values-lv-rLV/strings.xml b/amethyst/src/main/res/values-lv-rLV/strings.xml new file mode 100644 index 0000000000..107dafa9f3 --- /dev/null +++ b/amethyst/src/main/res/values-lv-rLV/strings.xml @@ -0,0 +1,118 @@ + + + Profila attēls + Jūsu profila attēls + 👀 + Kanāla attēls + Grupas attēls + Nezināms + Kopēt tekstu + Bloķēt / Sūdzēties + + Skatījumi + rediģēts + Pievienot + " un " + " Seko" + " Sekotāji" + "%1$s seko" + "%1$s sekotāji" + Profils + Maksāt + 😎 + Jauns kanāls + Kanāla nosaukums + Attēla URL + Apraksts + Saglabāt + Izveidot + Atcelt + Baiti + Kļūdas + Par mani + LN adrese + Augšupielādēt attēlu + Augšupielādē… + Bloķētie lietotāji + Piezīmes + Atbildes + Galerija + Sekot + Atbloķēt + Atbloķēt lietotāju + Lietotnes logo + Rādīt paroli + Slēpt paroli + Izveidot kontu + Izveidot jaunu kontu + Ģenerēt jaunu atslēgu + Atsvaidzināt + aprakstu uz + un attēlu uz + Kanāls izveidots + Noņemt + Automātiski + uz + Vienmēr tulkot %1$s + Nostr adrese + nekad + tagad + Kļūda + Pievienot jaunu kontu + Konti + Atlasīt kontu + Pievienot jaunu kontu + Kopēt tekstu + Dzēst + Sekot + @string/block_only + Dzēst + @string/block_only + Dzēst + + Iemesls + Bloķēt un sūdzēties + Bloķēt + Grāmatzīmes + Aizvērt pēc + dienām + Pievienot attēlu + Pievienot video + Pievienot dokumentu + Augšupielādē + Lejupielādē + Kļūda + Tor iestatījumi + + + Noklusējuma ports ir 9050 + Nederīgs porta numurs + Versija + Valstis + Valodas + Birkas + Valoda + Motīvs + Nevar augšupielādēt + Noteikumi + Labi + Meklēt + Profila attēls + Kategorija + Apģērbs + Elektronika + Grāmatas + Mājdzīvnieki + Sports + Māksla + Ēdiens + Augšupielādes kļūda: %1$s + Neizdevās augšupielādēt: %1$s + Meklēt + Paziņojumi + Paldies! + @string/torrent_download + Torrenta datne + Lejupielādēt + Nav uzstādītas torrent lietotnes, kas atvērtu un lejupielādētu datni. + diff --git a/amethyst/src/main/res/values-nl-rNL/strings.xml b/amethyst/src/main/res/values-nl-rNL/strings.xml index 5fab57d0c1..24b0d65769 100644 --- a/amethyst/src/main/res/values-nl-rNL/strings.xml +++ b/amethyst/src/main/res/values-nl-rNL/strings.xml @@ -96,8 +96,9 @@ Mijn geweldige groep Afbeelding URL Omschrijving - "Over ons.. " + "Over ons…" Waar denk je aan? + Schrijf een bericht… Verzenden Opslaan Maken @@ -205,6 +206,7 @@ met beschrijving van en afbeelding heeft chatnaam veranderd naar + Nieuw chatprofiel: omschrijving naar en afbeelding naar Verlaten @@ -212,6 +214,13 @@ Kanaal gemaakt "Kanaalinformatie veranderd naar" Publieke chat + Publieke chat metadata + Publieke chats zijn zichtbaar voor iedereen op Nostr en iedereen + kan eraan deelnemen. Ze zijn top voor open communities over specifieke onderwerpen. + Moderatie kan worden gecontroleerd door berichten te verwijderen op de relays + Relays + Voeg een 1–3 relays toe die deze groep hosten. + Nostr clients gebruiken deze instelling om te weten waar berichten van moeten worden gedownload en waarnaar uw berichten worden verzonden. berichten ontvangen verwijderen Automatisch @@ -281,7 +290,6 @@ Ontvolgen Volgen Verwijderen uit galerij - Verwijder deze media uit je galerij, je kunt het terug zien in je feed. Verzoek om te verwijderen Amethyst zal vragen om uw note te verwijderen van de relays waarmee u momenteel verbonden bent. Er is geen garantie dat uw note permanent wordt verwijderd van deze relays, of van andere relays waar het kan worden opgeslagen. Blokkeren @@ -300,7 +308,7 @@ Misbruik melden Alle geplaatste rapporteringen zijn openbaar. - Geef optioneel extra context over uw rapportering... + Geef optioneel extra context over uw rapportering… Extra context Reden Selecteer een reden… @@ -333,7 +341,7 @@ Zap minimum Zap maximum Consensus - (0-100)% + (0–100)% Sluit na dagen Kan niet stemmen @@ -519,6 +527,7 @@ Stuur naar Zap wallet Openen in Cashu wallet Token kopiëren + Openen in een andere app Geen Lightning-adres ingesteld Token gekopieerd naar klembord LIVE @@ -530,10 +539,7 @@ Uitloggen verwijdert al je lokale informatie. Zorg ervoor dat je een back-up hebt van je geheime sleutels om te voorkomen dat je je account kwijtraakt. Wilt u doorgaan? Gevolgde tags Relays - Note ontdekking Marktplaats - Live - Community Chats Goedgekeurde berichten Deze groep heeft geen beschrijving of regels. Praat met de eigenaar om er een toe te voegen @@ -598,6 +604,8 @@ Onderwerp Onderwerp van conversatie "\@User1, @User2, @User3" + Kan niet uploaden + Voeg eerst de bestemming van het bericht toe Leden van deze groep Uitleg aan leden De naam veranderen voor de nieuwe doelen. @@ -863,8 +871,9 @@ Bericht wijzigen Voorstel om een bericht te verbeteren Samenvatting van wijzigingen - Snelle oplossingen... + Snelle oplossingen… Accepteer de suggestie + Start video in een popup Downloaden Songteksten aan Songteksten uit @@ -930,4 +939,5 @@ Geen torrent apps geïnstalleerd om het bestand te openen en te downloaden. Selecteer een lijst om de feed te filteren Afmelden bij apparaatvergrendeling + Privébericht diff --git a/amethyst/src/main/res/values-nl/strings.xml b/amethyst/src/main/res/values-nl/strings.xml index 5fab57d0c1..e68d336353 100644 --- a/amethyst/src/main/res/values-nl/strings.xml +++ b/amethyst/src/main/res/values-nl/strings.xml @@ -96,7 +96,7 @@ Mijn geweldige groep Afbeelding URL Omschrijving - "Over ons.. " + "Over ons…" Waar denk je aan? Verzenden Opslaan @@ -281,7 +281,6 @@ Ontvolgen Volgen Verwijderen uit galerij - Verwijder deze media uit je galerij, je kunt het terug zien in je feed. Verzoek om te verwijderen Amethyst zal vragen om uw note te verwijderen van de relays waarmee u momenteel verbonden bent. Er is geen garantie dat uw note permanent wordt verwijderd van deze relays, of van andere relays waar het kan worden opgeslagen. Blokkeren @@ -300,7 +299,7 @@ Misbruik melden Alle geplaatste rapporteringen zijn openbaar. - Geef optioneel extra context over uw rapportering... + Geef optioneel extra context over uw rapportering… Extra context Reden Selecteer een reden… @@ -333,7 +332,7 @@ Zap minimum Zap maximum Consensus - (0-100)% + (0–100)% Sluit na dagen Kan niet stemmen @@ -530,10 +529,7 @@ Uitloggen verwijdert al je lokale informatie. Zorg ervoor dat je een back-up hebt van je geheime sleutels om te voorkomen dat je je account kwijtraakt. Wilt u doorgaan? Gevolgde tags Relays - Note ontdekking Marktplaats - Live - Community Chats Goedgekeurde berichten Deze groep heeft geen beschrijving of regels. Praat met de eigenaar om er een toe te voegen @@ -863,7 +859,7 @@ Bericht wijzigen Voorstel om een bericht te verbeteren Samenvatting van wijzigingen - Snelle oplossingen... + Snelle oplossingen… Accepteer de suggestie Downloaden Songteksten aan diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 889ecc45e7..94d56519b7 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -50,7 +50,14 @@ Używasz klucza publicznego, a klucze publiczne są tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby móc obserwować Używasz klucza publicznego, a klucze publiczne są tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby móc przestać obserwować Używasz klucza publicznego, a klucze publiczne są tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby móc ukryć słowo lub zdanie - Używasz klucza publicznego, a klucze publiczne są tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby móc pokazać słowo lub zdanie + Używasz klucza publicznego, a klucze publiczne są tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby móc pokazać wyraz lub zdanie + Używasz klucza publicznego i kluczy publicznych tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby móc zmieniać ustawienia + Używasz klucza publicznego i kluczy publicznych tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby móc wgrać + Używasz klucza publicznego i kluczy publicznych tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby móc zapisać się na udział w wydarzeniu + Nieautoryzowane odszyfrowywanie + Sygnatariusz nie autoryzował deszyfrowania wymaganego do wykonania tej operacji. Aktywuj deszyfrowanie NIP-44 w aplikacji podpisującej i spróbuj ponownie + Nie odnaleziono sygnatariusza + Czy aplikacja sygnatariusza została odinstalowana? Sprawdź, czy aplikacja sygnatariusza jest zainstalowana i czy ma to konto. Wyloguj się i zaloguj ponownie, jeśli aplikacja sygnatariusza uległa zmianie. Zapy Liczba wyświetleń Promuj @@ -71,6 +78,8 @@ Błąd podczas analizowania komunikatu o błędzie " Obserwowani" " Obserwujący" + "%1$s Obserwowanych" + "%1$s Obserwujących" Twój Profil Filtry bezpieczeństwa Wyloguj się @@ -96,9 +105,10 @@ Nazwa Grupy Adres Url obrazka Opis - "Informacja o grupie.. " + Nie znaleziono opisu + "Informacja o grupie…" Co masz na myśli? - Napisz wiadomość... + Napisz wiadomość… Wyślij Zapisz Utwórz @@ -137,6 +147,10 @@ Film zapisany w galerii filmów Nie udało się zapisać filmu Dodaj zdjęcie + Zrób zdjęcie + Nagraj wiadomość + Nagrywanie wiadomości + Kliknij i przytrzymaj aby nagrać wiadomość Wgrywanie… Użytkownik nie ma skonfigurowanego adresu LN, aby odbierać satsy "odpowiedz tutaj.. " @@ -155,6 +169,7 @@ Galeria "Obserwowani" "Zgłoszenia" + "%1$s Zgłoszeń" Więcej opcji " Transmitery" Strona www @@ -213,11 +228,20 @@ Porzuć obserwację Kanał utworzony "Informacje o kanale zmienione na" + Czat efemeryczny + Czat Transmitera + Czaty Transmitera + Czaty transmiterów to grupy czatów kontrolowane przez ich macierzyste transmitery. + Są one widoczne dla wszystkich użytkowników Nostr i każdy może w nich uczestniczyć. + Świetnie nadają się do tworzenia otwartych społeczności wokół określonych tematów. Niektóre z tych grup są efemeryczne + i dlatego wiadomości czatu znikają z czasem Czat Publiczny Metadane Czatu Publicznego Czaty publiczne są widoczne dla wszystkich użytkowników Nostr i każdy może w nich uczestniczyć. Są idealne dla otwartych społeczności skupionych wokół konkretnych tematów. Moderację można kontrolować, usuwając posty z transmiterów Transmitery Wstaw od 1 do 3 transmiterów, które obsługują tę grupę. Klienci Nostr używają tego ustawienia, aby wiedzieć, skąd pobierać wiadomości i do kogo je wysyłać. + Płatny transmiter + Wymusza Tor podczas łączenia odebranych wiadomości Usuń Automatycznie @@ -262,6 +286,7 @@ Błąd "Utworzony przez %1$s" "Wizerunek odznaki dla %1$s" + Wizerunek odznaki Otrzymałeś nową odznakę Nagroda przyznana dla Skopiowano tekst wpisu do schowka @@ -287,7 +312,7 @@ Porzuć Śledź Usuń z Galerii - Usuń te media z galerii, możesz dodać je ponownie później + Usuń ten plik z Galerii. Poproś o usunięcie Amethyst poprosi o usunięcie Twojego wpisu z aktualnie podłączonych transmiterów. Nie ma gwarancji, że Twój wpis zostanie trwale usunięty z tych lub z innych transmiterów, gdzie może być przechowywany. Zablokuj @@ -409,6 +434,7 @@ Nie Lista obserwowanych Obserwowane + Obserwuje przez proxy W pobliżu Wszystkie Zablokowane @@ -483,9 +509,18 @@ Zawsze ukrywaj wrażliwe treści Zawsze pokazuj wrażliwą zawartość Zawsze pokazuj ostrzeżenia dotyczące zawartości + Ukryj + Pokaż + Ostrzeżenie Polecane: Filtruj spam z nieznajomych Ostrzegaj, gdy posty zostały zgłoszone przez osoby które obserwujesz + Filtr spamu + Ukrywa posty od nieznajomych, które były dokładnie takie same przez 5 lub więcej razy + Ostrzeżenie o zgłoszeniach + Wyświetla komunikat ostrzegawczy, gdy posty zostały zgłoszone przez 5 lub więcej obserwowanych osób + Pokaż wrażliwe treści + Pokazuje komunikat ostrzegawczy, gdy autor wpisu oznaczył go jako wrażliwy Nowy Symbol Odzewu Brak wstępnie wybranych typów reakcji dla tego użytkownika. Przytrzymaj przycisk serce, aby zmienić Zapraiser @@ -537,17 +572,20 @@ Wylogowanie usuwa wszystkie informacje lokalne. Upewnij się, że masz kopię zapasową kluczy prywatnych, aby uniknąć utraty konta. Czy chcesz kontynuować? Obserwowane tagi Transmitery - Przegląd Wpisów + Obserwowani + Wyświetlenia + Algorytmy kanału Market - Na żywo - Społeczność + Transmisja na żywo + Społeczności Czaty Zatwierdzone posty Ta grupa nie ma opisu ani reguł. Porozmawiaj z właścicielem, aby je dodać - Ta społeczność nie ma opisu. Porozmawiaj z właścicielem, aby go dodać + Ta społeczność nie ma opisu. Zwróć się do właściciela, aby go dodać Treść wrażliwa Dodaje ostrzeżenie o wrażliwej treści przed wyświetleniem tej zawartości Ustawienia Aplikacji + Preferencje użytkownika Ustawienia Zawsze Tylko WiFi @@ -592,6 +630,8 @@ Dodaje Geohash twojej lokalizacji do wpisu. Użytkownicy będą wiedzieli, że jesteś mniej niż 5 km od bieżącej lokalizacji Post ekskluzywny dla lokalizacji Tylko obserwatorzy z twojej lokalizacji zobaczą to. Twoi ogólni obserwatorzy tego nie zobaczą. + Wpis hashtagowy + Tylko obserwujący ten hashtag zobaczą go. Twoi ogólni obserwujący go nie zobaczą. Pobieranie lokalizacji Brak dostępu do lokalizacji Dodaje ostrzeżenie o wrażliwych treściach przed wyświetleniem treści. Jest to idealne dla dowolnych treści NSFW lub treści, które niektóre osoby mogą uznać za obraźliwe lub przeszkadzające @@ -600,6 +640,7 @@ Aktywuj Publiczna Nowa Grupa Publiczna lub Prywatna + Transmiter Prywatna Do Temat @@ -665,6 +706,7 @@ Portfel %1$s Błąd podczas otwierania aplikacji podpisującej Nie można odnaleźć aplikacji podpisującej. Sprawdź, czy aplikacja nie została odinstalowana + Prośba o zalogowanie została odrzucona Odrzucono aplikację podpisującego Upewnij się, że aplikacja podpisującego autoryzuje tę transakcję Nie znaleziono portfeli do zapłacenia faktury z Lightning (Error: %1$s). Proszę zainstalować Lightning wallet, aby używać zapów @@ -792,6 +834,8 @@ Nowy post Nowe Króciaki: zdjęcia lub filmiki Nowy wpis w społeczności + Nowy produkt + Nowy GEO-ekskluzywny Wpis Otwórz wszystkie odzewy na ten post Zamknij wszystkie odzewy na ten post Odpowiedz @@ -846,12 +890,29 @@ Wstaw od 1 do 3 transmiterów przechowujących dane zdarzeń, których nikt inny nie widzi, takich jak Wersje robocze i/lub ustawienia aplikacji. Idealnie byłoby, gdyby te transmitery były lokalne lub wymagały uwierzytelnienia przed pobraniem treści każdego użytkownika. Transmitery ogólne Amethyst używa tych przekaźników, aby pobrać dla Ciebie posty. + Podłączone Transmitery + Aktualna lista używanych transmiterów Polecane Transmitery Dodaj te transmitery do głównej listy, aby otrzymywać wiadomości od wymienionych użytkowników. Transmitery wyszukujące Lista transmiterów używanych podczas wyszukiwania treści lub użytkowników. Tagowanie i wyszukiwanie nie będą działać, jeśli nie będą dostępne żadne opcje. Upewnij się, że zaimplementowano NIP-50. Lokalne Transmitery Lista transmiterów działających na tym urządzeniu. + Zaufane Transmitery + Zaufane Transmitery + Transmitery, którym ufasz, nie potrzebujesz połączenia Tor + Transmitery Proxy + Transmitery Proxy + Transmitery agregatora, z których aplikacja musi pobierać kanały, takie jak filter.nostr.wine. To zastępuje model skrzynki nadawczej i sprawia, że aplikacja łączy się tylko z transmiterami z listy. + Transmitery Nadawcze + Transmitery Nadawania + Transmitery, które specjalizują się w przesyłaniu wpisów do wszystkich innych transmiterów, takich jak sendit.nosflare.com. Amethyst doda ten transmiter do wszystkich nowych wydarzeń utworzonych przez użytkownika + Transmitery Indeksera + Transmitery indeksera + Transmitery, które specjalizują się w hostowaniu metadanych i list wszystkich użytkowników, takie jak purplepag.es. Amethyst użyje tych transmiterów do znalezienia użytkowników, którzy nie znajdują się na twoich listach. + Zablokowane Transmitery + Zablokowane Transmitery + Amethyst nigdy nie połączy się z tymi transmiterami Wspieraj deweloperów! Twoja darowizna pomaga nam coś zmienić. Każdy sat się liczy! Przekaż darowiznę @@ -941,4 +1002,11 @@ Wybierz listę, aby filtrować kanał Wyloguj się przy blokowaniu urządzenia Wiadomość prywatna + Publiczna wiadomość + Transmiter Czatu + Transmiter, z którym łączą się wszyscy użytkownicy tego czatu + Udostępnij zdjęcie… + Szukaj tagu: #%1$s + Nie tłumacz z + Języki wyświetlane tutaj nie będą tłumaczone. Wybierz język, aby usunąć go z listy języków nietłumaczonych. diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 3c683f12a2..40dd5406e6 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -51,6 +51,13 @@ Faça login com uma chave privada para poder remover seguidores Você está usando uma chave pública e as chaves públicas são somente de leitura. Faça login com uma chave privada para poder ocultar uma palavra ou frase. Você está usando uma chave pública e as chaves públicas são somente de leitura. Faça login com uma chave privada para poder mostrar uma palavra ou frase. + Você está usando uma chave pública, e chaves públicas são somente leitura. Faça login com uma chave privada para poder alterar as configurações + Você está usando uma chave pública, e chaves públicas são somente leitura. Faça login com uma chave privada para poder fazer upload + Você está usando uma chave pública, e chaves públicas são somente leitura. Faça login com uma chave privada para poder assinar eventos + Descriptografia não autorizada + O assinador não autorizou a descriptografia necessária para realizar esta operação. Ative as descriptografias NIP-44 no seu aplicativo de assinatura e tente novamente + Assinador não encontrado + O aplicativo de assinatura foi desinstalado? Verifique se ele está instalado e com esta conta. Saia e entre novamente se ele foi alterado. Zaps Contagem de visualizações Impulsionar @@ -71,6 +78,8 @@ Erro ao analisar mensagem de erro " Seguindo" " Seguidores" + "%1$s Seguindo" + "%1$s Seguidores" Perfil Filtros de segurança Sair @@ -96,6 +105,7 @@ Meu grupo URL da foto Descrição + Descrição não encontrada "Sobre nós.. " O que você está pensando? Escreva uma mensagem… @@ -137,6 +147,10 @@ Vídeo salvo na galeria de vídeos do telefone Falha ao salvar o vídeo Enviar Imagem + Tirar uma foto + Gravar uma mensagem + Gravar uma mensagem + Clique e segure para gravar uma mensagem Enviando… Usuário não tem um endereço lightning configurado para receber sats "responda aqui.. " @@ -155,6 +169,7 @@ Galeria "Seguindo" "Denúncias" + "%1$s Relatórios" Mais opções " Relés" Site @@ -213,14 +228,23 @@ Desseguir Canal criado "Informação do canal mudou para" + Chat Desaparecendo + Chat Relé + Chat Relés + Chat Relés são grupos de chat controlados por seu relé de origem. + Eles são visíveis para todos no Nostr e qualquer pessoa pode participar. + São ótimos para comunidades abertas em torno de tópicos específicos. Alguns desses grupos são efêmeros, + portanto, as mensagens desaparecem com o tempo Chat público Metadados do Chat Público Os chats públicos são visíveis para todos no Nostr, e qualquer pessoa pode participar deles. Eles são ótimos para comunidades abertas em torno de tópicos específicos. A moderação pode ser controlada excluindo postagens em seus relays Relés - Inserir entre 1-3 relés que hospedam esse grupo. + Inserir entre 1–3 relés que hospedam esse grupo. os clientes Nostr usam essa configuração para saber de onde fazer o download de mensagens e enviar suas mensagens. + Retransmissor pago + Forçar uso do Tor ao conectar postagens recebidas Remover Automaticamente @@ -263,6 +287,7 @@ Erro "Criado por %1$s" "Imagem da medalha para %1$s" + Imagem do prêmio Você recebeu uma nova medalha Medalha concedida para Texto copiado @@ -288,7 +313,7 @@ Remover seguidor Seguir Excluir da Galeria - Remover essa mídia de sua galeria, você pode adicionar mais tarde + Remover essa mídia de sua galeria. Pedir para excluir Amethyst solicitará que sua nota seja excluída dos relays aos quais você está conectado no momento. Não há garantia de que sua nota será excluída permanentemente desses relays ou de outros relays onde possa estar armazenada. Bloquear @@ -410,6 +435,7 @@ Não Lista de seguidores Seguindo + Segue via proxy Perto de mim Global Lista Silenciada @@ -484,9 +510,18 @@ Sempre ocultar conteúdo sensível Sempre mostrar conteúdo sensível Sempre mostrar avisos de conteúdo + Ocultar + Exibir + Avisar Recomenda: Filtrar spam de estranhos Avise quando as postagens tiverem denuncias de seus seguidores + Filtrar spam + Esconde publicações de desconhecidos que eram exatamente o mesmo por 5 ou mais vezes + Avisar em relatórios + Mostra uma mensagem de aviso quando as postagens tiverem 5 ou mais relatórios de suas seguintes + Mostrar conteúdo sensível + Mostra uma mensagem de aviso quando o autor da publicação a marcar como sensível Novo símbolo de reação Nenhum tipo de reação selecionado. Pressione e segure para alterar Arrecadação de Zaps @@ -538,10 +573,12 @@ Sair exclui todas as suas informações locais. Certifique-se de fazer backup de suas chaves privadas para evitar a perda de sua conta. Você quer continuar? Tags Seguidas Relés - Descoberta de Notas + Pacotes Seguir + Leituras + Algoritmos de feed Mercado - Ao vivo - Comunidade + Transmissões Ao Vivo + Comunidades Conversas Postagens Aprovadas Este grupo não tem uma descrição ou regras. Fale com o proprietário para adicionar @@ -549,6 +586,7 @@ Conteúdo sensível Adiciona aviso de conteúdo sensível antes de mostrar este conteúdo Preferências do aplicativo + Preferências do Usuário Configurações Sempre Somente wifi @@ -593,6 +631,8 @@ Adicione um geohash da sua localização à postagem. O público saberá que você está a 5 km (3 milhas) do local atual Postagem exclusiva de localização Somente seguidores da localização verão isso. Seus seguidores gerais não verão isso. + Postagem exclusiva de Hashtag + Somente seguidores da hashtag verão isso. Seus seguidores gerais não verão isso. Carregando localização Sem permissões para localização Adiciona aviso de conteúdo sensível antes de mostrar seu conteúdo. Isso é ideal para qualquer conteúdo NSFW ou conteúdo que algumas pessoas possam considerar ofensivo ou perturbador @@ -601,6 +641,7 @@ Ativar Público Novo Grupo Público ou Privado + Relé Privado Para Assunto @@ -666,6 +707,7 @@ Carteira %1$s Erro ao abrir o aplicativo para assinar evento O aplicativo de assinatura não pôde ser encontrado. Verifique se o aplicativo não foi desinstalado + Solicitação de assinatura rejeitada Solicitação de assinatura rejeitada Certifique-se de que a aplicação assinante autorizou esta transação Nenhuma carteira encontrada para pagar uma fatura Lightning (Erro: %1$s). Instale uma carteira Lightning para usar zaps @@ -793,6 +835,8 @@ Novo Post Novos Vídeos Curtos: imagens ou vídeos Nova Nota da Comunidade + Produto Novo + Nova Postagem Geo-Exclusiva Abrir todas as reações a esta postagem Fechar todas as reações a esta postagem Responder @@ -847,12 +891,29 @@ Insira entre 1–3 retransmissores para armazenar eventos que ninguém mais possa ver, como seus rascunhos e/ou configurações de aplicativo. Idealmente, esses relés são locais ou requerem autenticação antes de baixar o conteúdo de cada usuário. Relés Gerais Amethyst usa esses relés para baixar postagens para você. + Relays conectados + Lista atual de relays em uso Relés Recomendados Adicione os seguintes retransmissores à sua lista de Relés Gerais, a fim de receber mensagens dos usuários listados. Relés de Pesquisa Lista de relés para usar em pesquisas e marcação de usuários. A marcação e a pesquisa não funcionarão se não houver opções disponíveis. Relés locais Lista de relés que estão sendo executados neste dispositivo. + Relays confiáveis + Relays confiáveis + Relays em que você confia e que não precisam de conexão via Tor + Relays proxy + Relays proxy + Relays agregadores que o app usa para baixar seus feeds, como filter.nostr.wine. Isso substitui o modelo outbox e faz o app se conectar apenas aos relays das suas listas. + Relays de broadcast + Relays de broadcast + Relays especializados em enviar suas notas para todos os outros relays, como sendit.nosflare.com. O Amethyst adicionará esse relay a todos os novos eventos criados por você + Relays de indexação + Relays de indexação + Relays que armazenam metadados e listas de relays, como purplepag.es. O Amethyst usará esses relays para encontrar usuários fora de suas listas. + Relays bloqueados + Relays bloqueados + O Amethyst nunca se conectará a esses relays Zap os desenvolvedores! Sua doação nos ajuda a fazer a diferença. Cada sat conta! Doar agora @@ -942,4 +1003,11 @@ Selecione uma lista para filtrar o feed Terminar sessão no bloqueio do dispositivo Mensagem Privada + Mensagem pública + Relé de chat + O relé a qual todos os usuários deste chat se conectam + Compartilhar imagem… + Pesquisar hashtag: #%1$s + Não Traduzir de + Os idiomas mostrados aqui não serão traduzidos. Selecione um idioma para removê-lo e traduzi-lo novamente. diff --git a/amethyst/src/main/res/values-ru-rRU/strings.xml b/amethyst/src/main/res/values-ru-rRU/strings.xml index bfa9fda6da..2c8f565159 100644 --- a/amethyst/src/main/res/values-ru-rRU/strings.xml +++ b/amethyst/src/main/res/values-ru-rRU/strings.xml @@ -353,8 +353,6 @@ Трансляция выключена Трансляция закончена Рынок - Стримы - Сообщества Чаты У этой группы нет описания или правил. Поговорите с владельцем, чтобы добавить их У этого сообщества нет описания. Поговорите с владельцем, чтобы добавить diff --git a/amethyst/src/main/res/values-ru/strings.xml b/amethyst/src/main/res/values-ru/strings.xml index bfa9fda6da..2c8f565159 100644 --- a/amethyst/src/main/res/values-ru/strings.xml +++ b/amethyst/src/main/res/values-ru/strings.xml @@ -353,8 +353,6 @@ Трансляция выключена Трансляция закончена Рынок - Стримы - Сообщества Чаты У этой группы нет описания или правил. Поговорите с владельцем, чтобы добавить их У этого сообщества нет описания. Поговорите с владельцем, чтобы добавить diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index 4fa7802ba7..115c30c60b 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -15,8 +15,8 @@ Dešifriranje sporočila ni uspelo Slika skupine Eksplicitna vsebina - Nezaželjena vsebina - Iz tega releja izvira številna nezaželjena vsebina + Nezaželena vsebina + Iz tega releja izvira številna nezaželena vsebina Oponašanje Nedovoljeno obnašanje Drugo @@ -34,7 +34,7 @@ Zaprosi za izbris Blokiraj / Prijavi - Prijavi nazaželjeno vsebino / prevaro + Prijavi nazaželeno vsebino / prevaro Prijavi oponašalca Prijavi eksplicitno vsebino Prijavi nedovoljeno obnašanje @@ -77,8 +77,10 @@ Prijavi se s privatnim ključem, za prikaz skritih besed in stavkov Profilna pasica Plačilo uspešno Napaka pri razčlembi sporočila o napaki - " Sledim" + " Sledi" " Sledilcev" + "%1$s Sledi" + "%1$s Sledi" Profil Varnostni filtri Odjavi se @@ -90,8 +92,8 @@ Prijavi se s privatnim ključem, za prikaz skritih besed in stavkov Najlepša hvala! Vsota v Sat Pošlji Sat - Orodje za Emoji-je s skrivnostmi - Izberi Emoji za pošiljanje s skritim sporočilom + Orodje za emodžije s skrivnostmi + Izberi emodži za pošiljanje s skritim sporočilom Sprejem skrivnosti Moja skrita sporočila Vidni prefiksi @@ -106,7 +108,7 @@ Prijavi se s privatnim ključem, za prikaz skritih besed in stavkov Opis "O nas.. " Kaj imaš v mislih? - Napiši sporočilo... + Napiši sporočilo… Pošlji Shrani Ustvari @@ -163,6 +165,7 @@ Prijavi se s privatnim ključem, za prikaz skritih besed in stavkov Galerija "Sledi" "Reportaže" + "%1$s Prijave" Več možnosti " Releji" Spletna stran @@ -177,9 +180,9 @@ Prijavi se s privatnim ključem, za prikaz skritih besed in stavkov Uredi uporabnikove metapodatke Sledi Sledi nazaj - Odblokiraj + Deblokiraj Kopiraj uporabnikov ID - Odblokiraj uporabnika + Deblokiraj uporabnika "npub, uporabniško ime, tekst" Počisti Logo aplikacije @@ -221,7 +224,21 @@ Prijavi se s privatnim ključem, za prikaz skritih besed in stavkov Prenehaj slediti Kanal ustvarjen "Informacije kanala spremenjene v" + Izginjajoči klepeti + Rele klepet + Rele klepeti + Pogovori prek relejev so pogovorne skupine, ki jih nadzoruje njihov domači rele. + Vidne so vsem na Nostru in v njih lahko sodeluje kdorkoli. + So odlične za odprte skupnosti, povezane s specifičnimi temami. Nekatere od teh skupin so kratkotrajne + zato sporočila v klepetu sčasoma izginejo Javni pogovor + Metapodatki Javnega Klepeta + Javni klepeti so vidni vsem na Nostru in vsakdo + se lahko pridruži. So odlični za odprte skupnosti o specifičnih temah. + Moderiranje je možno z brisanjem objav na njihovih relejih + Releji + Vnesi 1–3 releje, ki gostijo to skupino. + Nostr odjemalci uporabljajo to nastavitev, da vedo, od kod prenesti tvoja sporočila in kam jih poslati. sprejete objave Odstrani Avtomatsko @@ -291,7 +308,6 @@ Prijavi se s privatnim ključem, za prikaz skritih besed in stavkov Prenehaj slediti Sledi Izbris iz galerije - Odstrani ta medij iz tvoje galerije, lahko ga dodaš nazaj kasneje. Prošnja za izbris Amethyst bo poslal prošnjo za izbris zapiska vsem relejem na katere ste povezani. Nobenega jamstva ni, da bo vaš zapisek trajno izbrisan iz teh relejev ali iz drugih relejev, kjer je morda shranjen. Blokiraj @@ -300,7 +316,7 @@ Prijavi se s privatnim ključem, za prikaz skritih besed in stavkov Prijavi Izbriši Ne prikaži ponovno - Nezaželjena vsebina ali prevara + Nezaželena vsebina ali prevara Nesramnost ali sovražno vedenje Zlonamerno lažno predstavljanje Golota ali grafična vsebina @@ -309,7 +325,7 @@ Prijavi se s privatnim ključem, za prikaz skritih besed in stavkov Blokiranje uporabnika bo skrilo njegove objave v tvoji aplikaciji. Tvoji zapiski še vedno ostajajo javno dostopni. Blokirani uporabniki so vidni v Varnost in filtri. Prijavi zlorabo - Vse poslane prijave bojo javno vidne + Vse poslane prijave bojo javno vidne. Opcijsko lahko dodaš dodatna pojasnila glede tvoje prijave… Dodaten kontekst Razlog @@ -325,7 +341,7 @@ Prijavi se s privatnim ključem, za prikaz skritih besed in stavkov Dodaj v javne zaznamke Odstrani iz privatnih zaznamkov Odstrani iz javnih zaznamkov - Wallet Connect storitev + Storitev Wallet Connect Pooblasti Nostr skrivnost (Nostr secret) za plačevanje z Zapi brez zapuščanja aplikacije. Nostr skrivnost (Nostr secret) hranite na varnem in, če je mogoče, uporabite zasebni rele Wallet Connect javni ključ Wallet Connect rele @@ -402,7 +418,7 @@ Prijavi se s privatnim ključem, za prikaz skritih besed in stavkov Napaka Tvoji releji (NIP-95) Datoteke so gostovane preko tvojih relejev. Nov NIP: Preveri če podpirajo - Zasebnostne nastavitve + Nastavitve zasebnosti Tor/Orbot nastavitve Poveži se preko tvojih Orbot nastavitev Prilagodi @@ -486,10 +502,19 @@ Prijavi se s privatnim ključem, za prikaz skritih besed in stavkov Ta objava vsebuje občutljivo vsebino, ki jo lahko nekateri smatrajo za žaljivo ali vznemirjajočo Vedno skrij občutljivo vsebino Vedno prikaži občutljivo vsebino - Vedno prikaži opozororilo o vsebini + Vedno prikaži opozorilo o vsebini + Skrij + Prikaži + Opozori Priporoča: - Filtriraj nezaželjeno vsebino od neznancev + Filtriraj nezaželeno vsebino od neznancev Opozori, ko objave vsebujejo prijave od oseb, ki jim slediš + Filtriraj nezaželeno vsebino + Skrije objave neznancev, ki so bile popolnoma enake več kot petkrat + Opozori o prijavah + Prikaže opozorilo, ko objava preseže 5 ali več prijav od oseb, ki jim slediš + Prikaži občutljivo vsebino + Prikaže opozorilo, če je avtor objavo označil kot občutljivo Nov reakcijski simbol Za tega uporabnika niso predhodno izbrane nobene vrste reakcij. Dolgo pritisnite na gumb za srce, da jih spremenite Zapraiser @@ -526,9 +551,10 @@ Prijavi se s privatnim ključem, za prikaz skritih besed in stavkov Plačilo Cashu žeton Unovči - Pošlji v Zap denarnico + Pošlji v Zap denarnico Odpri Cashu denarnico Kopiraj žeton + Odpri z drugo aplikacijo Lightning naslov ni nastavljen Žeton kopiran v odložišče V ŽIVO @@ -540,10 +566,9 @@ Prijavi se s privatnim ključem, za prikaz skritih besed in stavkov Odjava bo izbrisala vse vaše lokalne informacije. Poskrbite, da imate varnostno kopijo svojih zasebnih ključev, da se izognete izgubi računa. Ali želite nadaljevati? Spremljana vsebina Releji - Odkrivanje zapiskov + Sledi tropu + Branje Tržnica - V živo - Skupnosti Pogovori Odobrene objave Ta skupina nima opisa ali pravil. Obrnite se na lastnika, da jih doda @@ -559,6 +584,8 @@ Prijavi se s privatnim ključem, za prikaz skritih besed in stavkov Celovit Poenostavljen Optimiziran + Klasično + Moderno Sistemska Svetla Temna @@ -573,6 +600,8 @@ Prijavi se s privatnim ključem, za prikaz skritih besed in stavkov Skrij navigacijsko vrstico ob pomikanju UI način Izberi stil objave + Slog galerije profila + Izberi slog galerije Naloži sliko Pošiljatelji nezaželjenih vsebin Utišano. Klikni za vklop zvoka @@ -599,11 +628,14 @@ Prijavi se s privatnim ključem, za prikaz skritih besed in stavkov Aktiviraj Javno Nova javna ali zasebna skupina + Rele Zasebno Za Zadeva Tema pogovora "\@Uporabnik1, @Uporabnik2, @Uporabnik3" + Nalaganje ni uspelo + Najprej vnesi cilj sporočila Člani te skupine Razlaga članom Spreminjanje imena za dosego novih ciljev. @@ -722,7 +754,7 @@ Prijavi se s privatnim ključem, za prikaz skritih besed in stavkov Živjo, je to še na voljo? Prodaj stvar Naslov - Nokia 5210 + Nokia 3310 Stanje Kategorija Cena (v Satoshi) @@ -789,6 +821,7 @@ Prijavi se s privatnim ključem, za prikaz skritih besed in stavkov Nova objava Novi kratki mediji: slike ali posnetki Novo zapisek skupnosti + Nov produkt Odpri vse odzive na to objavo Zapri vse odzive na to objavo Odgovori @@ -872,27 +905,28 @@ Prijavi se s privatnim ključem, za prikaz skritih besed in stavkov Povzetek sprememb Hitri popravki… Sprejmi predlog + Zaženi video v pojavnem oknu Prenesi Vklopi besedilo Izklopi besedilo - Zapečateno sporočilo je izklopljeno. Kliknite, da vklopite zapečateno sporočilo. + Zapečateno sporočilo je izklopljeno. Kliknite, da vklopite zapečateno sporočilo Zapečateno sporočilo je vklopljeno. Kliknite, da izklopite zapečateno sporočilo. Pošlji Predvajaj uporabniško ime Pushpin Skeniraj QR kodo - Pojdite na stran tretjega ponudnika denarnice Alby. - Ni mogoče odgovoriti na osnutek zapiska. - Ni mogoče citirati osnutka zapiska. - Ni mogoče reagirati na osnutek zapiska. + Pojdite na stran tretjega ponudnika denarnice Alby + Ni mogoče odgovoriti na osnutek zapiska + Ni mogoče citirati osnutka zapiska + Ni mogoče reagirati na osnutek zapiska Osnutke zapisa ni mogoče Zap-niti Osnutek zapiska Iz zapiska Iščem aplikacijo Zahteva poslana, čakam na odgovor Zahtevam delo od DVM - Zahteva za plačilo poslana, čakam na potrditev iz vaše denarnice. - Čakam, da DVM potrdi plačilo ali pošlje rezultate. + Zahteva za plačilo poslana, čakam na potrditev iz vaše denarnice + Čakam, da DVM potrdi plačilo ali pošlje rezultate Neveljavna zahteva - Strežnik ne more ali noče obdelati zahteve. Nepooblaščeno - Uporabnik nima veljavnih overitvenih podatkov Potrebno je plačilo - Strežnik zahteva plačilo za dokončanje zahteve @@ -908,7 +942,7 @@ Prijavi se s privatnim ključem, za prikaz skritih besed in stavkov Predpogoj ni izpolnjen – Zahtevani predpogoji v glavi zahteve niso izpolnjeni s strani strežnika Tovor je prevelik – Zahteva presega omejitve, določene s strani strežnika, zato jo strežniška obdelava zavrača URI je predolg – URL, ki ga zahteva odjemalec, je predolg, da bi ga strežnik lahko obdelal. - Nepodprta vrsta medija – Zahteva uporablja medijski format, ki ga strežnik ne podpira. + Nepodprta vrsta medija – Zahteva uporablja medijski format, ki ga strežnik ne podpira Razpon ni zadovoljiv – Strežnik ne more izpolniti vrednosti, navedene v \"request’s Range header\" polju. Neizpolnjeno pričakovanje – Strežnik ne more izpolniti zahtev, navedenih v glavi zahteve \"Expect\" Potrebna nadgradnja – Strežnik zavrača obdelavo zahteve z uporabo trenutnega protokola, razen če odjemalec ne preklopi na drug protokol. @@ -937,4 +971,8 @@ Prijavi se s privatnim ključem, za prikaz skritih besed in stavkov Ni nameščenih torent aplikacij za odpiranje in prenos datoteke. Izberite seznam za filtriranje vira Odjava ob zaklepu naprave + Zasebno sporočilo + Rele za klepet + Rele, na katerega so povezani vsi uporabniki tega klepeta + Deli sliko… diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 43efc6398c..671dd309aa 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -51,6 +51,13 @@ Logga in med en privat nyckel för att kunna sluta följa Du använder en offentlig nyckel, och offentliga nycklar är endast läsbara. Logga in med en privat nyckel för att kunna dölja ett ord eller en mening Du använder en offentlig nyckel, och offentliga nycklar är endast läsbara. Logga in med en privat nyckel för att kunna visa ett ord eller en mening + Du använder en publik nyckel som är skrivskyddad. Logga in med en privat nyckel för att ändra inställningar + Du använder en publik nyckel som är skrivskyddad. Logga in med en privat nyckel för att ladda upp + Du använder en publik nyckel som är skrivskyddad. Logga in med en privat nyckel för att signera händelser + Otillåten dekryptering + Signatären har inte godkänt den dekryptering som krävs. Aktivera NIP-44-dekryptering i din signeringsapp och försök igen + Signatör saknas + Har signeringsappen avinstallerats? Kontrollera om den är installerad och innehåller det här kontot. Logga ut och in igen om den har ändrats. Zaps Antal visningar Boosta @@ -71,6 +78,8 @@ Fel vid tolkning av felmeddelande " Följer" " Följare" + "%1$s Följande" + "%1$s Följare" Profil Säkerhetsfilter Logga ut @@ -96,9 +105,10 @@ Min fantastiska grupp Bild Url Beskrivning + Beskrivning saknas "Om oss.. " Vad tänker du på? - Skriv ett meddelande... + Skriv ett meddelande… Dela Spara Skapa @@ -137,6 +147,10 @@ Videon sparad i telefonens videogalleri Det gick inte att spara videon Ladda upp bilden + Ta en bild + Spela in ett meddelande + Spela in ett meddelande + Tryck och håll in för att spela in ett meddelande Laddar upp… Användaren har inte en Lightningadressinställning för att ta emot sats "svara här.. " @@ -155,6 +169,7 @@ Galleri "Följer" "Rapporter" + "%1$s Rapporter" Fler alternativ " Reläer" Websida @@ -213,14 +228,23 @@ Sluta följa Kanal skapad "Kanalinformation ändrad till" + Försvinnande chatt + Relä Chatt + Relä Chattar + Relä chattar är chattgrupper som kontrolleras av deras hem relä. + De är synliga för alla på Nostr och vem som helst kan delta på dem. + De är bra för öppna gemenskaper kring specifika ämnen. Några av dessa grupper är kortlivade + och därmed försvinner chattmeddelanden över tiden Publik Chat Metadata för offentlig chatt Offentliga chattar är synliga för alla på Nostr och alla kan delta på dem. De är bra för öppna samhällen kring specifika ämnen. Moderation kan kontrolleras genom att ta bort inlägg på reläerna Reläer - Infoga mellan 1-3 reläer som är värd för denna grupp. + Infoga mellan 1–3 reläer som är värd för denna grupp. Nostr klienter använder denna inställning för att veta var du kan ladda ner meddelanden från och skicka dina meddelanden till. + Betald relä + Tvinga Tor vid anslutning mottagna inlägg Ta bort Automatiskt @@ -263,6 +287,7 @@ Fel "Skapad av %1$s" "Tilldelad Badge för %1$s" + Bild på utmärkelse Du har blivit tilldelad en ny Badge Badge utmärkelse tilldelas Kopierade anteckningstext till urklipp @@ -288,7 +313,7 @@ Sluta följa Följ Ta bort från galleriet - Ta bort detta media från ditt galleri, kan du lägga till det senare + Ta bort detta media från ditt galleri. Begär radering Amethyst kommer att begära att din anteckning tas bort från de reläer du för närvarande är ansluten till. Det finns ingen garanti för att din anteckning kommer att raderas permanent från dessa reläer eller från andra reläer där den kan lagras. Blockera @@ -410,6 +435,7 @@ Nej Följ lista Alla följare + Följer via proxy Runt mig Global Tyst listan @@ -483,9 +509,18 @@ Dölj alltid känsligt innehåll Visa alltid känsligt innehåll Visa alltid innehållsvarningar + Dölj + Visa + Varna Rekommenderas: Filtrera skräppost från främlingar Varna när inlägg har rapporter från dina följare + Filtrera spam + Döljer inlägg från främlingar som var exakt samma för 5 eller fler gånger + Varna vid rapporter + Visar ett varningsmeddelande när inlägg har 5 eller fler rapporter från följande + Visa känsligt innehåll + Visar ett varningsmeddelande när författaren till inlägget markerar det som känsligt Ny reaktionssymbol Inga reaktionstyper valda. Håll ned för att ändra Zap insamling @@ -537,10 +572,12 @@ Att logga ut raderar all din lokala information. Se till att ha dina privata nycklar säkerhetskopierade för att undvika att förlora ditt konto. Vill du fortsätta? Följda taggar Reläer - Upptäckt av anteckningar + Följpaket + Läsningar + Feed algoritmer Marknadsplats - Live - Gemenskap + Live-streams + Grupper Chattar Godkända Inlägg Denna grupp har ingen beskrivning eller regler. Prata med ägaren för att lägga till en. @@ -548,6 +585,7 @@ Känsligt innehåll Lägger till varning för känsligt innehåll innan detta innehåll visas. Appinställningar + Användarinställningar Inställningar Alltid Endast Wi-Fi @@ -592,6 +630,8 @@ Lägger till en Geohash av din plats i inlägget. Allmänheten kommer att veta att du befinner dig inom 5 km från nuvarande plats Plats-exklusivt inlägg Endast anhängare av platsen kommer att se den. Dina allmänna anhängare kommer inte att se den. + Hashtag-exklusivt inlägg + Endast anhängare av hashtaggen kommer att se den. Dina generella följare kommer inte att se den. Laddar position Inga platsbehörigheter Lägger till en varning för känsligt innehåll innan ditt innehåll visas. Detta är idealiskt för NSFW-innehåll (inte säkert för arbete) eller innehåll som vissa personer kan uppleva som stötande eller störande @@ -600,6 +640,7 @@ Aktivera Publik Ny offentlig eller privat grupp + Relä Privat Till Ämne @@ -665,6 +706,7 @@ Plånbok %1$s Fel vid öppning av signeringsapp Signeringsprogrammet kunde inte hittas. Kontrollera att appen inte har avinstallerats + Signeringsförfrågan avvisad Underteckningsbegäran avvisad Kontrollera att signeringsprogrammet har godkänt denna transaktion Inga plånböcker hittades för att betala en blixtfaktura (Fel: %1$s). Installera en blixtpengaplånbok för att använda zaps @@ -792,6 +834,8 @@ Nytt inlägg Nya kort: bilder eller videor Nytt Community-meddelande + Ny produkt + Nytt Geo-Exklusivt inlägg Öppna alla reaktioner på detta inlägg Stäng alla reaktioner på detta inlägg Svara @@ -846,12 +890,29 @@ Infoga mellan 1–3 reläer för att lagra händelser som ingen annan kan se, som dina Utkast och/eller appinställningar. Helst är dessa reläer antingen lokala eller kräver autentisering innan du laddar ner varje användares innehåll. Allmänna reläer Amethyst använder dessa reläer för att ladda ner inlägg åt dig. + Anslutna reläer + Aktuell lista över reläer som används Rekommenderade reläer Lägg till följande reläer till din Allmänna relälista för att ta emot inlägg från de listade användarna. Sökreläer Lista över reläer som används för sökning och taggning av användare. Taggning och sökning kommer inte att fungera om inga alternativ är tillgängliga. Lokala reläer Lista över reläer som körs i denna enhet. + Betrodda reläer + Betrodda reläer + Reläer du litar på och inte kräver Tor för + Proxyreläer + Proxyreläer + Aggregeringsreläer som används för att hämta flöden, t.ex. filter.nostr.wine + Sändande reläer + Sändande reläer + Reläer som skickar dina anteckningar till alla andra reläer + Indexerande reläer + Indexerande reläer + Reläer som lagrar metadata och listor över reläer, t.ex. purplepag.es + Blockerade reläer + Blockerade reläer + Amethyst kommer aldrig att ansluta till dessa reläer Zappa utvecklarna! Din donation hjälper oss att göra skillnad. Varje sat räknas! Donera nu @@ -941,4 +1002,11 @@ Välj en lista för att filtrera flödet Logga ut när enheten låses Privat meddelande + Offentligt meddelande + Chatt Relä + Reläet som alla användare av den här chatten ansluter till + Dela bild… + Sök hashtag: #%1$s + Översätt inte från + Språk som visas här kommer inte att översättas. Välj ett språk för att ta bort det och få det översatt igen. diff --git a/amethyst/src/main/res/values-sw-rKE/strings.xml b/amethyst/src/main/res/values-sw-rKE/strings.xml index 079c33fdf0..eaae02bd91 100644 --- a/amethyst/src/main/res/values-sw-rKE/strings.xml +++ b/amethyst/src/main/res/values-sw-rKE/strings.xml @@ -371,8 +371,6 @@ Kutoka kunaondoa taarifa zako za eneo la kuhifadhia data. Hakikisha una nakala za funguo zako binafsi ili kuepuka kupoteza akaunti yako. Je, unataka kuendelea? Mada Zinazofuatwa Usanidi wa Relays - Moja kwa Moja - Jumuiya Mazungumzo Machapisho Yaliyoidhinishwa Kikundi hiki hakitumii maelezo au kanuni. Ongea na mmiliki ili aongeze. diff --git a/amethyst/src/main/res/values-ta-rIN/strings.xml b/amethyst/src/main/res/values-ta-rIN/strings.xml index 4a82494ade..5471010d5e 100644 --- a/amethyst/src/main/res/values-ta-rIN/strings.xml +++ b/amethyst/src/main/res/values-ta-rIN/strings.xml @@ -93,7 +93,7 @@ படம் காட்சியகத்தில் சேமிக்கப்பட்டது படத்தைச் சேமிக்க இயலவில்லை படத்தைப் பதிவேற்று - பதிவேற்றுகிறது.... + பதிவேற்றுகிறது…. பயனர் ஸாட்கள் பெறுவதற்கு லைட்னிங் முகவரி அமைக்கவில்லை "இங்கு பதிலளிக்கவும்.. " குறிப்பு ID ஐ கிளிப்போர்டில் பிரதி எடுக்கும் @@ -292,11 +292,11 @@ எல்லா பின்பற்றப் படுவோர் முழுதளாவிய ## Tor மூலம் Orbot உடன் இணைக்க - \n\n1. [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android) இன்ஸ்டால் செய்க - \n2. Orbot-ஐ தொடங்குங்கள் + \n\n1. [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android) இன்ஸ்டால் செய்க + \n2. Orbot-ஐ தொடங்குங்கள் \n3. Orbot-இல் Socks போர்ட்டை செக் செய்க. இயல்புநிலையில் அது 9050 - \n4. வேண்டுமானால் Orbot-இல் போர்ட்டை மாற்றுங்கள் - \n5. இந்த திரையில் Socks போர்டை கட்டமைக்க + \n4. வேண்டுமானால் Orbot-இல் போர்ட்டை மாற்றுங்கள் + \n5. இந்த திரையில் Socks போர்டை கட்டமைக்க \n6. ஆக்டிவேட் பொத்தானை அழுத்தி Orbot-ஐ proxy-ஆக பயன்படுத்துங்கள் Orbot Socks போர்ட் செல்லாத போர்ட் நம்பர் @@ -365,8 +365,6 @@ வெளியேறுவதல் உங்கள் அனைத்து விவரங்களையும் நீக்கிவிடும். வெளியேறும் முன் உங்கள் கணக்கைத் தொலையாமல் இருக்க ரகசியசாவியை பத்திரமாக பிரதி செய்து சேமிக்கப்பட்டுள்ளதா என்று சரிபார்க்கவும். வெளியேற வேண்டுமா? பின்பற்றப்படும் சிட்டைகள் ரிலேகள் - நேரலை - சமூகம் அரட்டை அங்கீகரிக்கப்பட்ட குறிப்பு இந்த அரட்டைக் குழுவின் விளக்கமும் விதிகளும் இன்னும் சேர்க்கபடவில்லை. உரிமையாளரை அணுகி அவற்றை சேர்க்க கோரவும் diff --git a/amethyst/src/main/res/values-ta/strings.xml b/amethyst/src/main/res/values-ta/strings.xml index 4a82494ade..5471010d5e 100644 --- a/amethyst/src/main/res/values-ta/strings.xml +++ b/amethyst/src/main/res/values-ta/strings.xml @@ -93,7 +93,7 @@ படம் காட்சியகத்தில் சேமிக்கப்பட்டது படத்தைச் சேமிக்க இயலவில்லை படத்தைப் பதிவேற்று - பதிவேற்றுகிறது.... + பதிவேற்றுகிறது…. பயனர் ஸாட்கள் பெறுவதற்கு லைட்னிங் முகவரி அமைக்கவில்லை "இங்கு பதிலளிக்கவும்.. " குறிப்பு ID ஐ கிளிப்போர்டில் பிரதி எடுக்கும் @@ -292,11 +292,11 @@ எல்லா பின்பற்றப் படுவோர் முழுதளாவிய ## Tor மூலம் Orbot உடன் இணைக்க - \n\n1. [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android) இன்ஸ்டால் செய்க - \n2. Orbot-ஐ தொடங்குங்கள் + \n\n1. [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android) இன்ஸ்டால் செய்க + \n2. Orbot-ஐ தொடங்குங்கள் \n3. Orbot-இல் Socks போர்ட்டை செக் செய்க. இயல்புநிலையில் அது 9050 - \n4. வேண்டுமானால் Orbot-இல் போர்ட்டை மாற்றுங்கள் - \n5. இந்த திரையில் Socks போர்டை கட்டமைக்க + \n4. வேண்டுமானால் Orbot-இல் போர்ட்டை மாற்றுங்கள் + \n5. இந்த திரையில் Socks போர்டை கட்டமைக்க \n6. ஆக்டிவேட் பொத்தானை அழுத்தி Orbot-ஐ proxy-ஆக பயன்படுத்துங்கள் Orbot Socks போர்ட் செல்லாத போர்ட் நம்பர் @@ -365,8 +365,6 @@ வெளியேறுவதல் உங்கள் அனைத்து விவரங்களையும் நீக்கிவிடும். வெளியேறும் முன் உங்கள் கணக்கைத் தொலையாமல் இருக்க ரகசியசாவியை பத்திரமாக பிரதி செய்து சேமிக்கப்பட்டுள்ளதா என்று சரிபார்க்கவும். வெளியேற வேண்டுமா? பின்பற்றப்படும் சிட்டைகள் ரிலேகள் - நேரலை - சமூகம் அரட்டை அங்கீகரிக்கப்பட்ட குறிப்பு இந்த அரட்டைக் குழுவின் விளக்கமும் விதிகளும் இன்னும் சேர்க்கபடவில்லை. உரிமையாளரை அணுகி அவற்றை சேர்க்க கோரவும் diff --git a/amethyst/src/main/res/values-th-rTH/strings.xml b/amethyst/src/main/res/values-th-rTH/strings.xml index 0202f8c754..e03e052818 100644 --- a/amethyst/src/main/res/values-th-rTH/strings.xml +++ b/amethyst/src/main/res/values-th-rTH/strings.xml @@ -258,7 +258,6 @@ เลิกติดตาม ติดตาม ลบออกจากคลังภาพ - เอาออกจากคลังภาพ คุณสามารถเพิ่มใหม่ได้ภายหลัง ส่งคำขอให้ลบ Amethyst จะขอให้ลบโน้ตของคุณออกจากรีเลย์ที่คุณเชื่อมต่ออยู่ ไม่มีการรับประกันว่าโน้ตของคุณจะถูกลบออกอย่างถาวรจากรีเลย์เหล่านั้น หรือ จากรีเลย์อื่น ๆ ที่อาจเก็บไว้ บล๊อก @@ -455,10 +454,7 @@ การออกจากระบบจะลบข้อมูลทั้งหมดของคุณ ตรวจสอบให้แน่ใจว่าได้สํารองข้อมูล private key ไว้เพื่อหลีกเลี่ยงการสูญเสียบัญชีของคุณ คุณต้องการดําเนินการต่อหรือไม่? ติดตามแท็ก รีเลย์ - ค้นหาโน๊ตใหม่ ๆ ตลาด - ถ่ายทอดสด - ชุมชน ช่องสนทนา อนุมัติโพสต์ ชุมชนนี้ไม่มีคำอธิบายหรือกฏ พูดคุยกับเจ้าของเพื่อเพิ่มเติมสิ่งนี้ @@ -531,7 +527,7 @@ สร้างโดย กฏ เข้าสู่ระบบด้วย Amber - คุณกำลังทำอะไรอยู่... + คุณกำลังทำอะไรอยู่… เกิดข้อผิดพลาดในการแสดงข้อความ โหวตถูกให้น้ำหนักโดยจำนวน Zaps. คุณสามารถตั้งจำนวนเงินต่ำสุด และจำนวนครั้งสูงสุดในการโหวตเพื่อหลีกเลี่ยงการแสปมผลโพล. ใช้จำนวนเงินที่เท่ากันในทุกหัวข้อเพื่อให้การโหวตเป็นไปอย่างเท่าเทียม ปล่อยช่องว่าง หากต้องการให้โหวตเท่าใดก็ได้ แย่จัง ส่ง Zaps ไม่ได้ @@ -715,22 +711,22 @@ ตั้งค่ารีเลย์ส่วนตัวสำหรับรับข้อมูล การตั้งค่านี้จะแจ้งให้ทุกคนทราบว่าควรใช้รีเลย์ใดในการส่งข้อความถึงคุณ หากไม่มีการตั้งค่านี้ คุณอาจพลาดข้อความบางส่วนได้ ตัวเลือกที่ดี ได้แก่:\n- inbox.nostr.wine (เสียค่าบริการ)\n- you.nostr1.com (รีเลย์ส่วนตัว - เสียค่าบริการ) - ใส่รีเลย์ 1-3 ตัวเพื่อทำหน้าที่เป็นกล่องขาเข้าส่วนตัวของคุณ รีเลย์กล่องขาเข้า DM ควรยอมรับข้อความจากทุกคน แต่อนุญาตให้คุณดาวน์โหลดได้เท่านั้น + ใส่รีเลย์ 1–3 ตัวเพื่อทำหน้าที่เป็นกล่องขาเข้าส่วนตัวของคุณ รีเลย์กล่องขาเข้า DM ควรยอมรับข้อความจากทุกคน แต่อนุญาตให้คุณดาวน์โหลดได้เท่านั้น ตั้งค่าตอนนี้เลย ค้นหารีเลย์ ตั้งค่ารีเลย์เพื่อใช้ค้นหา การสร้างรายชื่อรีเลย์ที่ออกแบบมาเฉพาะสำหรับการค้นหาและการแท็กผู้ใช้จะช่วยปรับปรุงผลลัพธ์เหล่านี้ได้ - ใส่รีเลย์ 1-3 ตัวเพื่อใช้ในการค้นหาข้อมูลหรือแท็กผู้ใช้ ตรวจสอบให้แน่ใจว่ารีเลย์ที่คุณเลือกใช้รองรับ NIP-50 + ใส่รีเลย์ 1–3 ตัวเพื่อใช้ในการค้นหาข้อมูลหรือแท็กผู้ใช้ ตรวจสอบให้แน่ใจว่ารีเลย์ที่คุณเลือกใช้รองรับ NIP-50 ตัวเลือกที่ดี ได้แก่:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com ตั้งค่ารีเลย์ รีเลย์สาธารณะสำหรับหน้า Home - รีเลย์ประเภทนี้จะเก็บเนื้อหาทั้งหมดของคุณ Amethyst จะส่งโพสต์ของคุณไปยังรีเลย์เหล่านี้ และคนอื่น ๆ จะใช้รีเลย์เหล่านี้เพื่อค้นหาเนื้อหาของคุณ ใส่รีเลย์ 1-3 ตัว ซึ่งสามารถเป็นรีเลย์ส่วนตัว รีเลย์ที่ต้องเสียค่าบริการ หรือรีเลย์สาธารณะก็ได้ + รีเลย์ประเภทนี้จะเก็บเนื้อหาทั้งหมดของคุณ Amethyst จะส่งโพสต์ของคุณไปยังรีเลย์เหล่านี้ และคนอื่น ๆ จะใช้รีเลย์เหล่านี้เพื่อค้นหาเนื้อหาของคุณ ใส่รีเลย์ 1–3 ตัว ซึ่งสามารถเป็นรีเลย์ส่วนตัว รีเลย์ที่ต้องเสียค่าบริการ หรือรีเลย์สาธารณะก็ได้ รีเลย์ขาเข้าสาธารณะ - รีเลย์ประเภทนี้จะรับการตอบกลับ ความคิดเห็น การถูกใจ และการส่ง \"zap\" ทั้งหมดไปยังโพสต์ของคุณ ใส่รีเลย์ 1-3 ตัว และตรวจสอบให้แน่ใจว่ารีเลย์เหล่านี้ยอมรับโพสต์จากทุกคน + รีเลย์ประเภทนี้จะรับการตอบกลับ ความคิดเห็น การถูกใจ และการส่ง \"zap\" ทั้งหมดไปยังโพสต์ของคุณ ใส่รีเลย์ 1–3 ตัว และตรวจสอบให้แน่ใจว่ารีเลย์เหล่านี้ยอมรับโพสต์จากทุกคน รีเลย์ขาเข้าของ DM - ใส่รีเลย์ 1-3 ตัวเพื่อทำหน้าที่เป็นกล่องขาเข้าส่วนตัวของคุณ คนอื่นจะใช้รีเลย์เหล่านี้ในการส่งข้อความ DM ถึงคุณ รีเลย์กล่องขาเข้า DM ควรยอมรับข้อความจากทุกคน แต่อนุญาตให้คุณดาวน์โหลดได้เท่านั้น ตัวเลือกที่ดี ได้แก่:\n - inbox.nostr.wine (เสียค่าบริการ)\n - you.nostr1.com (รีเลย์ส่วนตัว - เสียค่าบริการ) + ใส่รีเลย์ 1–3 ตัวเพื่อทำหน้าที่เป็นกล่องขาเข้าส่วนตัวของคุณ คนอื่นจะใช้รีเลย์เหล่านี้ในการส่งข้อความ DM ถึงคุณ รีเลย์กล่องขาเข้า DM ควรยอมรับข้อความจากทุกคน แต่อนุญาตให้คุณดาวน์โหลดได้เท่านั้น ตัวเลือกที่ดี ได้แก่:\n - inbox.nostr.wine (เสียค่าบริการ)\n - you.nostr1.com (รีเลย์ส่วนตัว - เสียค่าบริการ) รีเลย์ส่วนตัว - ใส่รีเลย์ 1-3 ตัวเพื่อเก็บเหตุการณ์ที่ไม่มีใครสามารถเห็นได้ เช่น ร่างโพสต์ และการตั้งค่าแอปของคุณ รีเลย์เหล่านี้ควรเป็นรีเลย์ภายในเครื่องหรือรีเลย์ที่ต้องมีการยืนยันตัวตนก่อนที่จะดาวน์โหลดเนื้อหาของผู้ใช้แต่ละคน + ใส่รีเลย์ 1–3 ตัวเพื่อเก็บเหตุการณ์ที่ไม่มีใครสามารถเห็นได้ เช่น ร่างโพสต์ และการตั้งค่าแอปของคุณ รีเลย์เหล่านี้ควรเป็นรีเลย์ภายในเครื่องหรือรีเลย์ที่ต้องมีการยืนยันตัวตนก่อนที่จะดาวน์โหลดเนื้อหาของผู้ใช้แต่ละคน รีเลย์ทั่วไป Amethyst ใช้รีเลย์เหล่านี้เพื่อดาวน์โหลดโพสต์สำหรับคุณ รีเลย์ที่แนะนำ diff --git a/amethyst/src/main/res/values-th/strings.xml b/amethyst/src/main/res/values-th/strings.xml index 0202f8c754..e03e052818 100644 --- a/amethyst/src/main/res/values-th/strings.xml +++ b/amethyst/src/main/res/values-th/strings.xml @@ -258,7 +258,6 @@ เลิกติดตาม ติดตาม ลบออกจากคลังภาพ - เอาออกจากคลังภาพ คุณสามารถเพิ่มใหม่ได้ภายหลัง ส่งคำขอให้ลบ Amethyst จะขอให้ลบโน้ตของคุณออกจากรีเลย์ที่คุณเชื่อมต่ออยู่ ไม่มีการรับประกันว่าโน้ตของคุณจะถูกลบออกอย่างถาวรจากรีเลย์เหล่านั้น หรือ จากรีเลย์อื่น ๆ ที่อาจเก็บไว้ บล๊อก @@ -455,10 +454,7 @@ การออกจากระบบจะลบข้อมูลทั้งหมดของคุณ ตรวจสอบให้แน่ใจว่าได้สํารองข้อมูล private key ไว้เพื่อหลีกเลี่ยงการสูญเสียบัญชีของคุณ คุณต้องการดําเนินการต่อหรือไม่? ติดตามแท็ก รีเลย์ - ค้นหาโน๊ตใหม่ ๆ ตลาด - ถ่ายทอดสด - ชุมชน ช่องสนทนา อนุมัติโพสต์ ชุมชนนี้ไม่มีคำอธิบายหรือกฏ พูดคุยกับเจ้าของเพื่อเพิ่มเติมสิ่งนี้ @@ -531,7 +527,7 @@ สร้างโดย กฏ เข้าสู่ระบบด้วย Amber - คุณกำลังทำอะไรอยู่... + คุณกำลังทำอะไรอยู่… เกิดข้อผิดพลาดในการแสดงข้อความ โหวตถูกให้น้ำหนักโดยจำนวน Zaps. คุณสามารถตั้งจำนวนเงินต่ำสุด และจำนวนครั้งสูงสุดในการโหวตเพื่อหลีกเลี่ยงการแสปมผลโพล. ใช้จำนวนเงินที่เท่ากันในทุกหัวข้อเพื่อให้การโหวตเป็นไปอย่างเท่าเทียม ปล่อยช่องว่าง หากต้องการให้โหวตเท่าใดก็ได้ แย่จัง ส่ง Zaps ไม่ได้ @@ -715,22 +711,22 @@ ตั้งค่ารีเลย์ส่วนตัวสำหรับรับข้อมูล การตั้งค่านี้จะแจ้งให้ทุกคนทราบว่าควรใช้รีเลย์ใดในการส่งข้อความถึงคุณ หากไม่มีการตั้งค่านี้ คุณอาจพลาดข้อความบางส่วนได้ ตัวเลือกที่ดี ได้แก่:\n- inbox.nostr.wine (เสียค่าบริการ)\n- you.nostr1.com (รีเลย์ส่วนตัว - เสียค่าบริการ) - ใส่รีเลย์ 1-3 ตัวเพื่อทำหน้าที่เป็นกล่องขาเข้าส่วนตัวของคุณ รีเลย์กล่องขาเข้า DM ควรยอมรับข้อความจากทุกคน แต่อนุญาตให้คุณดาวน์โหลดได้เท่านั้น + ใส่รีเลย์ 1–3 ตัวเพื่อทำหน้าที่เป็นกล่องขาเข้าส่วนตัวของคุณ รีเลย์กล่องขาเข้า DM ควรยอมรับข้อความจากทุกคน แต่อนุญาตให้คุณดาวน์โหลดได้เท่านั้น ตั้งค่าตอนนี้เลย ค้นหารีเลย์ ตั้งค่ารีเลย์เพื่อใช้ค้นหา การสร้างรายชื่อรีเลย์ที่ออกแบบมาเฉพาะสำหรับการค้นหาและการแท็กผู้ใช้จะช่วยปรับปรุงผลลัพธ์เหล่านี้ได้ - ใส่รีเลย์ 1-3 ตัวเพื่อใช้ในการค้นหาข้อมูลหรือแท็กผู้ใช้ ตรวจสอบให้แน่ใจว่ารีเลย์ที่คุณเลือกใช้รองรับ NIP-50 + ใส่รีเลย์ 1–3 ตัวเพื่อใช้ในการค้นหาข้อมูลหรือแท็กผู้ใช้ ตรวจสอบให้แน่ใจว่ารีเลย์ที่คุณเลือกใช้รองรับ NIP-50 ตัวเลือกที่ดี ได้แก่:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com ตั้งค่ารีเลย์ รีเลย์สาธารณะสำหรับหน้า Home - รีเลย์ประเภทนี้จะเก็บเนื้อหาทั้งหมดของคุณ Amethyst จะส่งโพสต์ของคุณไปยังรีเลย์เหล่านี้ และคนอื่น ๆ จะใช้รีเลย์เหล่านี้เพื่อค้นหาเนื้อหาของคุณ ใส่รีเลย์ 1-3 ตัว ซึ่งสามารถเป็นรีเลย์ส่วนตัว รีเลย์ที่ต้องเสียค่าบริการ หรือรีเลย์สาธารณะก็ได้ + รีเลย์ประเภทนี้จะเก็บเนื้อหาทั้งหมดของคุณ Amethyst จะส่งโพสต์ของคุณไปยังรีเลย์เหล่านี้ และคนอื่น ๆ จะใช้รีเลย์เหล่านี้เพื่อค้นหาเนื้อหาของคุณ ใส่รีเลย์ 1–3 ตัว ซึ่งสามารถเป็นรีเลย์ส่วนตัว รีเลย์ที่ต้องเสียค่าบริการ หรือรีเลย์สาธารณะก็ได้ รีเลย์ขาเข้าสาธารณะ - รีเลย์ประเภทนี้จะรับการตอบกลับ ความคิดเห็น การถูกใจ และการส่ง \"zap\" ทั้งหมดไปยังโพสต์ของคุณ ใส่รีเลย์ 1-3 ตัว และตรวจสอบให้แน่ใจว่ารีเลย์เหล่านี้ยอมรับโพสต์จากทุกคน + รีเลย์ประเภทนี้จะรับการตอบกลับ ความคิดเห็น การถูกใจ และการส่ง \"zap\" ทั้งหมดไปยังโพสต์ของคุณ ใส่รีเลย์ 1–3 ตัว และตรวจสอบให้แน่ใจว่ารีเลย์เหล่านี้ยอมรับโพสต์จากทุกคน รีเลย์ขาเข้าของ DM - ใส่รีเลย์ 1-3 ตัวเพื่อทำหน้าที่เป็นกล่องขาเข้าส่วนตัวของคุณ คนอื่นจะใช้รีเลย์เหล่านี้ในการส่งข้อความ DM ถึงคุณ รีเลย์กล่องขาเข้า DM ควรยอมรับข้อความจากทุกคน แต่อนุญาตให้คุณดาวน์โหลดได้เท่านั้น ตัวเลือกที่ดี ได้แก่:\n - inbox.nostr.wine (เสียค่าบริการ)\n - you.nostr1.com (รีเลย์ส่วนตัว - เสียค่าบริการ) + ใส่รีเลย์ 1–3 ตัวเพื่อทำหน้าที่เป็นกล่องขาเข้าส่วนตัวของคุณ คนอื่นจะใช้รีเลย์เหล่านี้ในการส่งข้อความ DM ถึงคุณ รีเลย์กล่องขาเข้า DM ควรยอมรับข้อความจากทุกคน แต่อนุญาตให้คุณดาวน์โหลดได้เท่านั้น ตัวเลือกที่ดี ได้แก่:\n - inbox.nostr.wine (เสียค่าบริการ)\n - you.nostr1.com (รีเลย์ส่วนตัว - เสียค่าบริการ) รีเลย์ส่วนตัว - ใส่รีเลย์ 1-3 ตัวเพื่อเก็บเหตุการณ์ที่ไม่มีใครสามารถเห็นได้ เช่น ร่างโพสต์ และการตั้งค่าแอปของคุณ รีเลย์เหล่านี้ควรเป็นรีเลย์ภายในเครื่องหรือรีเลย์ที่ต้องมีการยืนยันตัวตนก่อนที่จะดาวน์โหลดเนื้อหาของผู้ใช้แต่ละคน + ใส่รีเลย์ 1–3 ตัวเพื่อเก็บเหตุการณ์ที่ไม่มีใครสามารถเห็นได้ เช่น ร่างโพสต์ และการตั้งค่าแอปของคุณ รีเลย์เหล่านี้ควรเป็นรีเลย์ภายในเครื่องหรือรีเลย์ที่ต้องมีการยืนยันตัวตนก่อนที่จะดาวน์โหลดเนื้อหาของผู้ใช้แต่ละคน รีเลย์ทั่วไป Amethyst ใช้รีเลย์เหล่านี้เพื่อดาวน์โหลดโพสต์สำหรับคุณ รีเลย์ที่แนะนำ diff --git a/amethyst/src/main/res/values-tr-rTR/strings.xml b/amethyst/src/main/res/values-tr-rTR/strings.xml index 0c9d8ae64a..35621aa3a6 100644 --- a/amethyst/src/main/res/values-tr-rTR/strings.xml +++ b/amethyst/src/main/res/values-tr-rTR/strings.xml @@ -90,9 +90,9 @@ Resim galeriye kaydedildi Resim galeriye kaydedilemedi Resim Yükle - Yükleniyor... + Yükleniyor… Kullanıcının sats alabileceği bir lightning adresi kurulu değil - "buraya yanıtlayın..." + "buraya yanıtlayın…" Paylaşmak için Not ID\'sini panoya kopyalar Kanal ID\'sini (Not) panoya kopyala Kanal Üstveri\'sini Editle diff --git a/amethyst/src/main/res/values-tr/strings.xml b/amethyst/src/main/res/values-tr/strings.xml index 0c9d8ae64a..b404a3f538 100644 --- a/amethyst/src/main/res/values-tr/strings.xml +++ b/amethyst/src/main/res/values-tr/strings.xml @@ -1,5 +1,5 @@ - + QR Code\'a yönlendir QR Göster Profil Resmi @@ -90,9 +90,9 @@ Resim galeriye kaydedildi Resim galeriye kaydedilemedi Resim Yükle - Yükleniyor... + Yükleniyor… Kullanıcının sats alabileceği bir lightning adresi kurulu değil - "buraya yanıtlayın..." + "buraya yanıtlayın…" Paylaşmak için Not ID\'sini panoya kopyalar Kanal ID\'sini (Not) panoya kopyala Kanal Üstveri\'sini Editle diff --git a/amethyst/src/main/res/values-uk-rUA/strings.xml b/amethyst/src/main/res/values-uk-rUA/strings.xml index b8943d7438..3c028d4482 100644 --- a/amethyst/src/main/res/values-uk-rUA/strings.xml +++ b/amethyst/src/main/res/values-uk-rUA/strings.xml @@ -381,8 +381,6 @@ Вихід з облікового запису видаляє всю вашу локальну інформацію. Переконайтеся, що ваші особисті ключі були збережені, щоб уникнути втрати вашого облікового запису. Ви хочете продовжити? Відстежувані теги Торгівельний майданчик - Наживо - Спільнота Чати Затверджені публікації Ця група не має опису або правил. Зверніться до автора, щоб додати один diff --git a/amethyst/src/main/res/values-uk/strings.xml b/amethyst/src/main/res/values-uk/strings.xml index b8943d7438..3c028d4482 100644 --- a/amethyst/src/main/res/values-uk/strings.xml +++ b/amethyst/src/main/res/values-uk/strings.xml @@ -381,8 +381,6 @@ Вихід з облікового запису видаляє всю вашу локальну інформацію. Переконайтеся, що ваші особисті ключі були збережені, щоб уникнути втрати вашого облікового запису. Ви хочете продовжити? Відстежувані теги Торгівельний майданчик - Наживо - Спільнота Чати Затверджені публікації Ця група не має опису або правил. Зверніться до автора, щоб додати один diff --git a/amethyst/src/main/res/values-uz-rUZ/strings.xml b/amethyst/src/main/res/values-uz-rUZ/strings.xml index 4fec38d164..cc4646375a 100644 --- a/amethyst/src/main/res/values-uz-rUZ/strings.xml +++ b/amethyst/src/main/res/values-uz-rUZ/strings.xml @@ -1,18 +1,48 @@ - Kamerani QR kodga yo\'naltiring + Point to the QR Code QR kodni ko‘rsatish Profil rasmini Sizning profil rasmingiz QR kodni skanerlang Baribir postni ko‘rsat Ushbu post yashirilgan, chunki u siz yashirgan foydalanuvchilar yoki so\'zlarni eslatadi - Post ________ tomonidan bloklangan yoki xabar qilingan - Post yuklanmoqda yoki estafetalar ro‘yxatingizda topilmadi + Post kim tomonidan bloklangan yoki bildirgan + Post yuklanmoqda yoki relaylardan ro‘yxatingizda topilmadi 👀 Kanal rasmi Havola qilingan post topilmadi Xabarni shifrdan chiqarib bo\'lmadi Guruh rasmi - + Uyatsiz kontent + Spam + Ushbu relaydan kelayotgan spam vaziyatlarining soni + Soxtalashtirish + Noqonuniy xatti-harakat + Boshqa sabab + Ta’qib + Noma\'lum + Relay belgisi + Nomaʼlum muallif + Matnni nusxalash + Muallif identifikatorini nusxalash + Post identifikatorini nusxalash + Tarqatish + Vaqtni belgilash + Vaqt: Tasdiqlash kutilmoqda + OTS: Kutilmoqda + Oʻchirishni soʻrash + Bloklash / Bildirish + + Spam / Firibgarlik deb bildirish + Soxta profilni bildirish + Uyatsiz kontentni bildirish + Noqonuniy xatti-harakatni bildirish + Zararli dasturni bildirish + Moderatorga shikoyat qilish + Zararli dastur + Moderator + Sizda ommaviy kalit faol. Ommaviy kalitlar orqali faqat o‘qish mumkin. Xabar yozish uchun, maxfiy kalit bilan avtorizatsiyadan o‘ting + Sizda ommaviy kalit faol. Ommaviy kalitlar orqali faqat o‘qish mumkin. Postni targ‘ib qilish uchun, maxfiy kalit bilan avtorizatsiyadan o‘ting + Sizda ommaviy kalit faol. Ommaviy kalitlar orqali faqat o‘qish mumkin. Postga reaksiya qilish uchun, maxfiy kalit bilan avtorizatsiyadan o‘ting diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index b70f711a26..2ff9f4e653 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -20,6 +20,7 @@ 冒充 非法行为 其它 + 骚扰行为 未知 中继器图标 未知作者 @@ -50,6 +51,13 @@ 你正在使用公钥,公钥是只读的。使用私钥登录以便能够取消关注 你正在使用公钥,公钥是只读的。使用私钥登录以便能够隐藏单词或句子 你正在使用公钥,公钥是只读的。使用私钥登录以便能够显示单词或句子 + 你正在使用公钥,公钥是只读的。要更改设置请用私钥登录 + 你正在使用公钥,公钥是只读的。要上传请使用私钥登录 + 你正在使用公钥,公钥是只读的。要报名参加活动请使用私钥登录 + 未授权的解密 + 签名人没有授权进行此操作所需的解密。在签名人应用中激活 NIP-44 解密并重试 + 未找到签名人 + 签名人应用被卸载了吗?检查是否安装了签名人以及是否签名人有该账户。注销并再次登录,签名人应用已更改。 打闪 浏览次数 提升 @@ -70,6 +78,8 @@ 解析错误信息时出错 " 关注" " 粉丝" + "%1$s 正在关注" + "%1$s 关注者" 个人档案 安全过滤器 退出 @@ -95,8 +105,10 @@ 我的精彩群聊 图片链接 描述 + 未找到描述 "关于我们.. " 你在想什么? + 写一条消息… 发布 保存 创建 @@ -135,6 +147,10 @@ 视频已保存到媒体库 保存视频失败 上传图片 + 拍照 + 录制消息 + 录制消息 + 单击并按住来录制消息 上传中… 用户尚未设置闪电地址以接收聪 "🔏在此回复… " @@ -153,6 +169,7 @@ 相册 "关注" "举报" + "%1$s 举报" 更多选项 "中继" 网站 @@ -204,13 +221,30 @@ 描述为 并上传图片 将聊天名称修改为 + 新的聊天资料: 描述更改为 图片更改为 离开 取关 频道已创建 "频道信息已更改为" + 临时聊天 + 中继聊天 + 中继聊天 + 中继聊天是由主中继控制的聊天群。 + 它们对 Nostr 上的所有人可见,任何人均可加入它们。 + 它们对围绕特定话题的开放社区很好。这些群中的一些是临时存在的 + 因而,聊天消息会随着时间消失 公共聊天 + 公开聊天元数据 + 公开聊天对所有 Nostr 用户都是可见的,任何人都可以: + 参与这些聊天。这对特定话题建立的开放式社区来说很重要 + 通过删除中继上的帖子实现内容审核控制 + 中继器 + 使用 1~3 个中继托管这个群组。 + 让 Nostr 客户端知道应该使用这些中继配置来发送和下载消息。 + 付费中继 + 连接时强制使用 Tor 收到文章 移除 自动 @@ -255,6 +289,7 @@ 错误 "由 %1$s 创建" "颁发给 %1$s 的徽章图片" + \"徽章奖品图片 你收到了新的徽章奖励 徽章奖励授予 文本已复制到剪贴板 @@ -280,7 +315,7 @@ 取关 关注 从相册中删除 - 从相册中删除此媒体,但仍然可供浏览 + 从媒体库中删除此媒体。 请求删贴 Amethyst 将请求你的记录从当前连接的中继器中删除。不能保证你发布的笔记将被永久从那些中继器或其他存储笔记的中继器中删除。 阻止 @@ -299,10 +334,10 @@ 违规报告 所有举报都将被公开可见 - 可选择提供关于你的举报的附加背景... + 可选择提供关于你的举报的附加背景… 附加背景 理由 - 选择一个理由... + 选择一个理由… 发布举报 阻止与举报 仅阻止 @@ -402,6 +437,7 @@ 关注列表 所有关注 + 通过代理关注 周围的人 全球 静音列表 @@ -476,9 +512,18 @@ 始终隐藏敏感内容 始终显示敏感内容 始终显示内容警告 + 隐藏 + 显示 + 警告 推荐: 过滤来自陌生人的垃圾信息 当帖子有你的关注的报告时警告 + 过滤垃圾信息 + 隐藏陌生人的五次或五次以上完全相同的帖子 + 举报警示 + 当帖子有来自你关注的五次及以上举报时显示警告消息 + 显示敏感内容 + 当帖子作者将帖子标记为敏感时显示警告消息 新回应符号 未选择回应类型。长按可更改 Zapraiser @@ -518,6 +563,7 @@ 发送到打闪钱包 在 Cashu 钱包中打开 复制令牌 + 在另一个应用中打开 未设置闪电地址 已将代币复制至剪贴板 直播 @@ -529,10 +575,12 @@ 登出将删除你的本地信息。 请确保备份你的私钥以避免失去你的帐户。你想要继续吗? 已关注的标签 中继器 - 笔记发现 + 关注包 + 次浏览 + Feed算法 市场 - 直播 - 社区 + 直播 + 社区 聊天 批准的帖子 此群组没有描述或规则。联系群主来添加 @@ -540,6 +588,7 @@ 敏感内容 在显示此内容之前添加敏感内容警告 偏好设置 + 用户首选项 设置 始终 仅限 WiFi @@ -583,7 +632,9 @@ 将位置显示为 将你所在位置的地理位置添加到帖子。公众会知道你在当前位置的5公里之内(3英里) 位置限定帖子 - 只有处于同一地理位置的追随者才能看到贴文。其他追随者无法看到。 + 只有处于同一地理位置的关注者才能看到贴文。其他追随者无法看到。 + 话题标签专属帖子 + 只有话题标签的关注者才会看到它。您的一般关注者不会看到它。 加载位置中 没有位置信息权限 在显示你的内容之前添加敏感的内容警告。针对任何 NSFW 内容或一些人可能觉得有冒犯性或令人不安的内容。 @@ -592,11 +643,14 @@ 启用 公开 新建公开或私人群组 + 中继 私人 主题 对话主题 "\@User1、@User2、@User3" + 无法上传 + 需要首先输入消息的目标位置 此群组成员 对成员的解释 为新目标更改名称。 @@ -655,6 +709,7 @@ 钱包 %1$s 打开签名应用时出错 找不到签名应用。检查应用是否已被卸载 + 签名请求被拒绝了 签名请求被拒绝了 请确保签名应用程序已授权此交易 找不到支付闪电发票的钱包(错误:%1$s)。请安装闪电钱包来使用打闪 @@ -782,6 +837,8 @@ 新帖子 新短篇媒体:图像或视频 新社区笔记 + 新产品 + 新建地理位置专属帖子 展开对此帖子的所有回应 收起对此帖子的所有回应 回复 @@ -836,12 +893,29 @@ 设置 1 ~ 3 个中继用于保存其他人无法看到的事件,例如您的草稿和软件设置。理想情况下,这些应该是本地中继,或者是在获取用户内容时需要经过身份验证的中继。 通用中继 Amethyst 会通过这些中继为您获取帖子。 + 已连接的中继 + 当前正在使用的中继列表 推荐的中继器 在通用中继列表中添加下列中继以接收列表中用户的帖子。 搜索中继 用于关键词和标签检索的中继列表。如果没有可用选项,也就无法使用关键词和标签检索。需要确保它们支持 NIP-50。 本地中继 在该设备中运行的中继列表。 + 可信中继 + 可信中继 + 你信任的无需 Tor 连接的中继 + 代理中继 + 代理中继 + 聚合器中继下载 feed 必须使用的应用的流量,类似 filter.nostr.wine。这替代 outbox 模型,让应用只连接到列表中的中继。 + 广播中继 + 广播中继 + 专门推送便笩到所有其他中继的中继,类似 sendit.nosflare.com。Amethyst 会将这个中继添加到所有你所做的新事件 + 索引器中继 + 索引器中继 + 专门托管每个人的元数据和中继列表的中继,类似 purplepag.es。Amethyst将使用这些中继来查找不在列表中的用户。 + 屏蔽的中继 + 屏蔽的中继 + Amethyst 永远不会连接到这些中继 打闪开发人员! 你的捐赠帮助我们做出不同的贡献。每个聪都很重要! 立即捐款 @@ -864,6 +938,7 @@ 变动摘要 快速修正… 接受建议 + 在弹出窗口中播放视频 下载 开启歌词 关闭歌词 @@ -929,4 +1004,12 @@ 没有用于打开和下载文件的 Torrent 客户端 选择一个用于过滤订阅源的列表 当设备锁定时注销 + 私信 + 公开消息 + 聊天中继 + 此聊天所有用户都连接到的中继 + 分享图片… + 搜索话题标签:#%1$s + 不要翻译自 + 此处显示的语言不会被翻译。请选择一种语言来移除它并重新翻译它。 diff --git a/amethyst/src/main/res/values-zh-rHK/strings.xml b/amethyst/src/main/res/values-zh-rHK/strings.xml index a5afbefe49..baa1d2ee16 100644 --- a/amethyst/src/main/res/values-zh-rHK/strings.xml +++ b/amethyst/src/main/res/values-zh-rHK/strings.xml @@ -219,10 +219,10 @@ 違規報告 所有舉報都將被公開可見 - 可選擇提供關於您的舉報的額外背景... + 可選擇提供關於您的舉報的額外背景… 其他背景 理由 - 選擇一個理由... + 選擇一個理由… 發佈舉報 阻止與舉報 僅阻止 diff --git a/amethyst/src/main/res/values-zh-rTW/strings.xml b/amethyst/src/main/res/values-zh-rTW/strings.xml index 6269f3ec51..dc10677446 100644 --- a/amethyst/src/main/res/values-zh-rTW/strings.xml +++ b/amethyst/src/main/res/values-zh-rTW/strings.xml @@ -256,7 +256,6 @@ 取關 關注 從相冊中刪除 - 從相冊中刪除此媒體,但仍然可供瀏覽 請求刪貼 Amethyst 將請求您的記錄從當前連接的中繼器中刪除。不能保證您發佈的筆記將被永久從那些中繼器或其他存儲筆記的中繼器中刪除。 屏蔽 @@ -278,7 +277,7 @@ 可選擇提供關於你的舉報的附加背景… 附加背景 原因 - 選擇原因... + 選擇原因… 發佈舉報 屏蔽並舉報 屏蔽 @@ -446,10 +445,7 @@ 登出將刪除你的本地信息。請確保備份你的私鑰以避免失去你的帳戶。你想要繼續嗎? 以關注的標籤 中繼器 - 筆記發現 市場 - 直播 - 社群 聊天 批准的帖子 此群組沒有描述或規則。聯繫群主來添加 @@ -702,22 +698,22 @@ 設定你的私人收件匣中繼 此設定讓大家知道向你發送訊息時要使用哪些中繼。如果沒有它們,你可能會錯過一些訊息。 不錯的選擇是:\n - inbox.nostr.wine(付費)\n - you.nostr1.com(個人中繼 - 付費) - 輸入 1-3 個中繼作為你的私人收件匣。 私信收件匣中繼應該接受任何人的任何訊息,但只允許你下載它們。 + 輸入 1–3 個中繼作為你的私人收件匣。 私信收件匣中繼應該接受任何人的任何訊息,但只允許你下載它們。 立即設定 搜索中繼器 設置你的搜索中繼器 建立專為搜索和標記用戶設計的中繼列表能夠改善這些結果。 - 輸入 1-3 個中繼以在搜尋內容或標記用戶時使用。確保你選擇的中繼器實施 NIP-50 + 輸入 1–3 個中繼以在搜尋內容或標記用戶時使用。確保你選擇的中繼器實施 NIP-50 不錯的選擇是:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com 中繼器設置 公開主頁中繼 - 這種中繼將保存所有你的內容。Amethyst 會將你的帖子發送到此處,而他人則將使用這些中繼查找你的內容。輸入 1-3 個中繼。它們可以是個人中繼、付費中繼或公開中繼。 + 這種中繼將保存所有你的內容。Amethyst 會將你的帖子發送到此處,而他人則將使用這些中繼查找你的內容。輸入 1–3 個中繼。它們可以是個人中繼、付費中繼或公開中繼。 公開收件匣中繼 - 這種中繼接收所有對你的帖子的回覆、評論、點贊和打閃。輸入 1-3 個中繼並確認它們接收來自任何人的事件。 + 這種中繼接收所有對你的帖子的回覆、評論、點贊和打閃。輸入 1–3 個中繼並確認它們接收來自任何人的事件。 私信收件匣中繼 - 輸入 1-3 個中繼作爲你的私人收件匣。他人將使用這些中繼向你發送私信。私信收件匣中繼應該接受來自任何人的訊息,但只允許你下載它們。不錯的選擇是:\n - inbox.nostr.wine (付費)\n - you.nostr1.com (私人中繼 - 付費) + 輸入 1–3 個中繼作爲你的私人收件匣。他人將使用這些中繼向你發送私信。私信收件匣中繼應該接受來自任何人的訊息,但只允許你下載它們。不錯的選擇是:\n - inbox.nostr.wine (付費)\n - you.nostr1.com (私人中繼 - 付費) 私人中繼 - 輸入 1-3 個中繼用於存儲別人看不到的事件,例如你的草稿和/或應用設置。理想情況下,這些中繼要么是本地的,要么需要在下載每個用戶的內容之前進行驗證。 + 輸入 1–3 個中繼用於存儲別人看不到的事件,例如你的草稿和/或應用設置。理想情況下,這些中繼要么是本地的,要么需要在下載每個用戶的內容之前進行驗證。 一般中繼器 Amethyst 使用這些中繼爲你下載帖子。 推薦的中繼器 diff --git a/amethyst/src/main/res/values-zh/strings.xml b/amethyst/src/main/res/values-zh/strings.xml index d1cf305cc8..48ab496800 100644 --- a/amethyst/src/main/res/values-zh/strings.xml +++ b/amethyst/src/main/res/values-zh/strings.xml @@ -225,10 +225,10 @@ 违规报告 所有举报都将被公开可见 - 可选择提供关于您的举报的额外背景... + 可选择提供关于您的举报的额外背景… 其他背景 理由 - 选择一个理由... + 选择一个理由… 发布举报 阻止与举报 仅阻止 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 9cf975c27e..3612aae152 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -55,6 +55,16 @@ You are using a public key and public keys are read-only. Login with a Private key to be able to unfollow You are using a public key and public keys are read-only. Login with a Private key to be able to hide a word or sentence You are using a public key and public keys are read-only. Login with a Private key to be able to show a word or sentence + You are using a public key and public keys are read-only. Login with a Private key to be able to change settings + You are using a public key and public keys are read-only. Login with a Private key to be able to upload + + You are using a public key and public keys are read-only. Login with a Private key to be able to sign for events + + Unauthorized Decryption + The signer did not authorize a decryption that is required to make this operation. Activate NIP-44 decryptions in your signer app and try again + + Signer not found + Was the Signer app uninstalled? Check if the signer is installed and has this account. Log off and Log in again of the signer app has changed. Zaps @@ -77,6 +87,9 @@ Error parsing error message " Following" " Followers" + "%1$s Following" + "%1$s Followers" + Profile Security Filters Logout @@ -104,9 +117,10 @@ My Awesome Group Picture Url Description + Description not found "About us.. " What\'s on your mind? - Write a message... + Write a message… Post Save Create @@ -145,6 +159,10 @@ Video saved to the phone\'s video gallery Failed to save the video Upload Image + Take a picture + Record a message + Record a message + Click and hold to record a message Uploading… User does not have a lightning address set up to receive sats "reply here.. " @@ -163,6 +181,7 @@ Gallery "Follows" "Reports" + "%1$s Reports" More Options " Relays" Website @@ -221,6 +240,15 @@ Unfollow Channel created "Channel Information changed to" + + Disappearing Chat + Relay Chat + Relay Chats + Relay chats are chat groups controlled by their home relay. + They are visible to everyone on Nostr and anyone can participate on them. + They are great for open communities around specific topics. Some of these groups are ephemeral + and thus chat messages disappear over time + Public Chat Public Chat Metadata Public chats are visible to everyone on Nostr and anyone @@ -228,9 +256,12 @@ Moderation can be controlled by deleting posts on the its relays Relays - Insert between 1-3 relays that host this group. + Insert between 1–3 relays that host this group. Nostr clients use this setting to know where to download messages from and send your messages to. + Paid relay + Forces Tor when connecting + posts received Remove sats @@ -243,7 +274,7 @@ Always translate to %1$s Never translate from %1$s Nostr Address - LNURL... + LNURL… never now h @@ -280,6 +311,7 @@ Error "Created by %1$s" "Badge award image for %1$s" + "Badge award image You Received a new Badge Award Badge award granted to Copied note text to clipboard @@ -313,7 +345,7 @@ Unfollow Follow Delete from Gallery - Remove this media from your Gallery. + Remove this media from your Gallery. Request Deletion Amethyst will request that your note be deleted from the relays you are currently connected to. There is no guarantee that your note will be permanently deleted from those relays, or from other relays where it may be stored. Block @@ -467,6 +499,7 @@ Follow List All Follows + Follows via Proxy Around Me Global Mute List @@ -572,10 +605,21 @@ Always show sensitive content Always show content warnings + Hide + Show + Warn + Recommends: Filter spam from strangers Warn when posts have reports from your follows + Filter spam + Hides posts from strangers that were exactly the same for 5 or more times + Warn on reports + Shows a warning message when posts have 5 or more reports from your follows + Show sensitive content + Shows a warning message when the author of the post marked it as sensitive + New Reaction Symbol No reaction types pre-selected for this user. Long press on the heart button to change @@ -635,10 +679,12 @@ Relays - Note Discovery + Follow Packs + Reads + Feed Algorithms Marketplace - Live - Community + Live Streams + Communities Chats Approved Posts @@ -649,6 +695,7 @@ Adds sensitive content warning before showing this content App Preferences + User Preferences Settings Always @@ -707,6 +754,10 @@ Location-exclusive Post Only followers of the location will see it. Your general followers won\'t see it. + Hashtag-exclusive Post + Only followers of the hashtag will see it. Your general followers won\'t see it. + + Loading location No Location Permissions @@ -718,6 +769,7 @@ Public New Public or Private Group + Relay Private To Subject @@ -800,6 +852,7 @@ Wallet %1$s Error opening signer app The signer app could not be found. Check if the app hasn\'t been uninstalled + Sign request rejected Signer Application Rejected Make sure the signer application has authorized this transaction @@ -960,6 +1013,8 @@ New Post New Shorts: images or videos New Community Note + New Product + New Geo-Exclusive Post Open all reactions to this post Close all reactions to this post @@ -1027,6 +1082,11 @@ Insert between 1–3 relays to store events no one else can see, like your Drafts and/or app settings. Ideally, these relays are either local or require authentication before downloading each user\'s content. General Relays Amethyst uses these relays to download posts for you. + + + Connected Relays + Current list of relays being used + Recommended Relays Add the following relays to your General Relays list in order to receive posts from the listed users. Search Relays @@ -1034,6 +1094,26 @@ Local Relays List of relays that are running in this device. + Trusted Relays + Trusted Relays + Relays you trust to not need a Tor connection for + + Proxy Relays + Proxy Relays + Aggregator relays the app must use to download your feeds from, like filter.nostr.wine. This replaces the outbox model and will make the app connect to only the relays in your lists. + + Broadcast Relays + Broadcast Relays + Relays that specialize in pushing your notes to all of the other relays, like sendit.nosflare.com. Amethyst will add this relay to all new events made by you + + Indexer Relays + Indexer Relays + Relays that specialize in hosting everyone\'s metadata and relay lists, like purplepag.es. Amethyst will use these relays to find users that are not in your lists. + + Blocked Relays + Blocked Relays + Amethyst will never connect to these relays + Zap the Devs! Your donation helps us make a difference. Every sat counts! Donate Now @@ -1140,4 +1220,14 @@ Log off on device lock Private Message + Public Message + + Chat Relay + The relay that all users of this chat connect to + Share image… + + Search hashtag: #%1$s + + Don\'t Translate From + Languages shown here will not be translated. Select a language to remove it and have it translated again. diff --git a/amethyst/src/main/res/xml/file_paths.xml b/amethyst/src/main/res/xml/file_paths.xml index a075ef96b4..0b339a9cf9 100644 --- a/amethyst/src/main/res/xml/file_paths.xml +++ b/amethyst/src/main/res/xml/file_paths.xml @@ -3,4 +3,6 @@ + + diff --git a/amethyst/src/play/AndroidManifest.xml b/amethyst/src/play/AndroidManifest.xml index 5426033b5e..c48a43decf 100644 --- a/amethyst/src/play/AndroidManifest.xml +++ b/amethyst/src/play/AndroidManifest.xml @@ -8,7 +8,7 @@ + tools:ignore="ExportedService"> diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt index dbb4de9b4d..52ccf2b9f8 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt @@ -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 diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/TranslationsCache.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/TranslationsCache.kt new file mode 100644 index 0000000000..037d97b8e1 --- /dev/null +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/TranslationsCache.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.lang + +import android.util.LruCache +import com.vitorpamplona.amethyst.ui.components.TranslationConfig + +object TranslationsCache { + val cache = LruCache(100) + + fun get(content: String): TranslationConfig = cache.get(content) ?: TranslationConfig(content, null, null, false) + + fun set( + content: String, + config: TranslationConfig, + ) { + cache.put(content, config) + } +} diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt index 05da543539..57e045df1f 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt @@ -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 @@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrC import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateZapChannel import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -39,7 +40,13 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.launch class PushNotificationReceiverService : FirebaseMessagingService() { - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + // Exists to avoid exceptions stopping the coroutine + val exceptionHandler = + CoroutineExceptionHandler { _, throwable -> + Log.e("AmethystCoroutine", "Caught exception: ${throwable.message}", throwable) + } + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler) private val eventCache = LruCache(100) // this is called when a message is received diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt index 56ea9ce763..be36c33f9d 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt @@ -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 diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt index becd2ea954..5640705216 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt @@ -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,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.components +import android.os.Build import androidx.compose.runtime.Composable import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel @@ -28,7 +29,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.checkifItNeed @OptIn(ExperimentalPermissionsApi::class) @Composable fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesViewModel) { - checkifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + checkifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel) + } } @Composable diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index 4dc9b42f62..03b7d5f790 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -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 @@ -54,8 +54,9 @@ import androidx.compose.ui.unit.dp import androidx.core.os.ConfigurationCompat import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService +import com.vitorpamplona.amethyst.service.lang.TranslationsCache import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled -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.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -80,18 +81,7 @@ fun TranslatableRichTextViewer( accountViewModel: AccountViewModel, nav: INav, ) { - var translatedTextState by - remember(id) { mutableStateOf(TranslationConfig(content, null, null, false)) } - - TranslateAndWatchLanguageChanges(content, accountViewModel) { result -> - if ( - !translatedTextState.result.equals(result.result, true) || - translatedTextState.sourceLang != result.sourceLang || - translatedTextState.targetLang != result.targetLang - ) { - translatedTextState = result - } - } + var translatedTextState by translateAndWatchLanguageChanges(content, id, accountViewModel) CrossfadeIfEnabled(targetState = translatedTextState, accountViewModel = accountViewModel) { RenderText( @@ -254,10 +244,8 @@ private fun TranslationMessage( } }, onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.account.prefer(source, target, source) - langSettingsPopupExpanded = false - } + accountViewModel.prefer(source, target, source) + langSettingsPopupExpanded = false }, ) DropdownMenuItem( @@ -287,7 +275,7 @@ private fun TranslationMessage( }, onClick = { scope.launch(Dispatchers.IO) { - accountViewModel.account.prefer(source, target, target) + accountViewModel.prefer(source, target, target) langSettingsPopupExpanded = false } }, @@ -321,10 +309,8 @@ private fun TranslationMessage( } }, onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.account.updateTranslateTo(lang) - langSettingsPopupExpanded = false - } + langSettingsPopupExpanded = false + accountViewModel.updateTranslateTo(lang) }, ) } @@ -333,15 +319,37 @@ private fun TranslationMessage( } } +@Composable +fun translateAndWatchLanguageChanges( + content: String, + id: String, + accountViewModel: AccountViewModel, +): MutableState { + var translatedTextState = remember(id) { mutableStateOf(TranslationsCache.get(content)) } + + TranslateAndWatchLanguageChanges( + content, + accountViewModel, + ) { result -> + if ( + !translatedTextState.value.result.equals(result.result, true) || + translatedTextState.value.sourceLang != result.sourceLang || + translatedTextState.value.targetLang != result.targetLang + ) { + TranslationsCache.set(content, result) + translatedTextState.value = result + } + } + + return translatedTextState +} + @Composable fun TranslateAndWatchLanguageChanges( content: String, accountViewModel: AccountViewModel, onTranslated: (TranslationConfig) -> Unit, ) { - // Don't automatically update translations. - // val accountState by accountViewModel.accountLanguagesLiveData.observeAsState() - LaunchedEffect(Unit) { // This takes some time. Launches as a Composition scope to make sure this gets cancel if this // item gets out of view. diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/CharsetTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/CharsetTest.kt index 785a27140d..54f0161a57 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/CharsetTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/CharsetTest.kt @@ -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 diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt index dc17c9c7da..8ec1602d0c 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt @@ -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,8 +23,9 @@ package com.vitorpamplona.amethyst import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.actions.Dao import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote import com.vitorpamplona.quartz.nip19Bech32.entities.NPub -import com.vitorpamplona.quartz.nip19Bech32.entities.Note import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -43,9 +44,9 @@ class NewMessageTaggerKeyParseTest { com.vitorpamplona.amethyst.model .Note(hex) - override suspend fun checkGetOrCreateAddressableNote(hex: String) = + override suspend fun getOrCreateAddressableNote(address: Address) = com.vitorpamplona.amethyst.model - .Note(hex) + .AddressableNote(address) } @Test @@ -53,10 +54,10 @@ class NewMessageTaggerKeyParseTest { val result = NewMessageTagger(message = "", dao = dao) .parseDirtyWordForKey("note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn") - assertTrue(result?.key?.entity is Note) + assertTrue(result?.key?.entity is NNote) assertEquals( "1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", - (result?.key?.entity as? Note)?.hex, + (result?.key?.entity as? NNote)?.hex, ) assertEquals(null, result?.restOfWord) } @@ -79,10 +80,10 @@ class NewMessageTaggerKeyParseTest { val result = NewMessageTagger(message = "", dao = dao) .parseDirtyWordForKey("note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,") - assertTrue(result?.key?.entity is Note) + assertTrue(result?.key?.entity is NNote) assertEquals( "1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", - (result?.key?.entity as? Note)?.hex, + (result?.key?.entity as? NNote)?.hex, ) assertEquals(",", result?.restOfWord) } @@ -105,10 +106,10 @@ class NewMessageTaggerKeyParseTest { val result = NewMessageTagger(message = "", dao = dao) .parseDirtyWordForKey("@note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,") - assertTrue(result?.key?.entity is Note) + assertTrue(result?.key?.entity is NNote) assertEquals( "1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", - (result?.key?.entity as? Note)?.hex, + (result?.key?.entity as? NNote)?.hex, ) assertEquals(",", result?.restOfWord) } @@ -133,10 +134,10 @@ class NewMessageTaggerKeyParseTest { .parseDirtyWordForKey( "nostr:note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,", ) - assertTrue(result?.key?.entity is Note) + assertTrue(result?.key?.entity is NNote) assertEquals( "1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", - (result?.key?.entity as? Note)?.hex, + (result?.key?.entity as? NNote)?.hex, ) assertEquals(",", result?.restOfWord) } @@ -163,10 +164,10 @@ class NewMessageTaggerKeyParseTest { .parseDirtyWordForKey( "Nostr:note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,", ) - assertTrue(result?.key?.entity is Note) + assertTrue(result?.key?.entity is NNote) assertEquals( "1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", - (result?.key?.entity as? Note)?.hex, + (result?.key?.entity as? NNote)?.hex, ) assertEquals(",", result?.restOfWord) } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/SplitterTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/SplitterTest.kt index 4a9572d428..a3561eb327 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/SplitterTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/SplitterTest.kt @@ -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 diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/UrlDecoderTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/UrlDecoderTest.kt index 2e7e9ebc0c..f1dddd8f1e 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/UrlDecoderTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/UrlDecoderTest.kt @@ -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 diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/zaps/UserProfileZapsFeedFilterTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/zaps/UserProfileZapsFeedFilterTest.kt index 18c6e19ead..6e2f58188c 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/zaps/UserProfileZapsFeedFilterTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/zaps/UserProfileZapsFeedFilterTest.kt @@ -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,7 +21,7 @@ package com.vitorpamplona.amethyst.service.zaps import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.dal.UserProfileZapsFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.dal.UserProfileZapsFeedFilter import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import io.mockk.every diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModelTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModelTest.kt deleted file mode 100644 index 76053df108..0000000000 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModelTest.kt +++ /dev/null @@ -1,130 +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.ui.actions - -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.tags.people.PTag -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import io.mockk.MockKAnnotations -import io.mockk.every -import io.mockk.impl.annotations.MockK -import io.mockk.mockk -import io.mockk.mockkObject -import io.mockk.unmockkAll -import io.mockk.verify -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.test.runTest -import org.junit.After -import org.junit.Before -import org.junit.Test - -@ExperimentalCoroutinesApi -class NewPostViewModelTest { - @MockK - lateinit var accountViewModel: AccountViewModel - - @MockK(relaxed = true) - lateinit var replyingTo: Note - - private lateinit var newPostViewModelUnderTest: NewPostViewModel - - @Before - fun setup() { - mockkObject(LocalCache) - every { LocalCache.getOrCreateUser(any()) } returns mockk() - newPostViewModelUnderTest = NewPostViewModel() - MockKAnnotations.init(this) - } - - @After - fun tearDown() { - unmockkAll() - } - - @Test - fun `test load with mentions`() = - runTest { - // Arrange: Setup with non empty mentions - every { accountViewModel.account } returns mockk() - - val textNoteEvent = mockk(relaxed = true) - every { textNoteEvent.mentions() } returns listOf(PTag("user1"), PTag("user2")) - every { replyingTo.event } returns textNoteEvent - - every { accountViewModel.userProfile() } returns mockk(relaxed = true) - every { accountViewModel.account.userProfile() } returns mockk(relaxed = true) - every { accountViewModel.account.myEmojis } returns mockk>>(relaxed = true) - - // Act: Call load with mentions - newPostViewModelUnderTest.init(accountViewModel) - newPostViewModelUnderTest.load(replyingTo, quote = null, fork = null, version = null, draft = null) - - // Assert - // Two mentions should call LocalCache.getOrCreateUser twice - verify(exactly = 2) { LocalCache.getOrCreateUser(any()) } - } - - @Test - fun `test load with zero mentions`() = - runTest { - every { accountViewModel.account } returns mockk() - - // Arrange: Setup Note with zero mentions - val textNoteEvent = mockk(relaxed = true) - every { textNoteEvent.mentions() } returns emptyList() - every { replyingTo.event } returns textNoteEvent - - every { accountViewModel.userProfile() } returns mockk(relaxed = true) - - // Act: Call load with empty mentions - newPostViewModelUnderTest.load(replyingTo, quote = null, fork = null, version = null, draft = null) - - // Assert - // With no mentions LocalCache.getOrCreateUser should not be called - verify(exactly = 0) { LocalCache.getOrCreateUser(any()) } - } - - @Test - fun `test load with empty mentions`() = - runTest { - every { accountViewModel.account } returns mockk() - - // Arrange: Setup empty mentions - val textNoteEvent = mockk(relaxed = true) - every { textNoteEvent.mentions() } returns emptyList() - every { replyingTo.event } returns textNoteEvent - - every { accountViewModel.userProfile() } returns mockk(relaxed = true) - - // Act: Call load with empty mentions - newPostViewModelUnderTest.load(replyingTo, quote = null, fork = null, version = null, draft = null) - - // Assert - // Verify LocalCache.getOrCreateUser(it) is not called with empty hex, it will crash the app - verify(exactly = 0) { LocalCache.getOrCreateUser(any()) } - } -} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/MediaCompressorTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/MediaCompressorTest.kt index 94e8940735..5b07e82f2a 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/MediaCompressorTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/MediaCompressorTest.kt @@ -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 @@ -81,7 +81,7 @@ class MediaCompressorTest { ) // Verify - verify(exactly = 0) { VideoCompressor.start(any(), any(), any(), any(), any(), any(), any()) } + verify(exactly = 0) { VideoCompressor.start(any(), any(), any(), any(), any(), any()) } coVerify(exactly = 0) { Compressor.compress(any(), any(), any(), any()) } } @@ -102,7 +102,7 @@ class MediaCompressorTest { ) // Verify - verify(exactly = 0) { VideoCompressor.start(any(), any(), any(), any(), any(), any(), any()) } + verify(exactly = 0) { VideoCompressor.start(any(), any(), any(), any(), any(), any()) } coVerify(exactly = 0) { Compressor.compress(any(), any(), any(), any()) } } @@ -115,7 +115,7 @@ class MediaCompressorTest { val uri = mockk() val contentType = "video" - every { VideoCompressor.start(any(), any(), any(), any(), any(), any(), any()) } returns Unit + every { VideoCompressor.start(any(), any(), any(), any(), any(), any()) } returns Unit // Execution MediaCompressor().compress( @@ -126,7 +126,7 @@ class MediaCompressorTest { ) // Verify - verify(exactly = 1) { VideoCompressor.start(any(), any(), any(), any(), any(), any(), any()) } + verify(exactly = 1) { VideoCompressor.start(any(), any(), any(), any(), any(), any()) } } @Test diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/SupportedContentTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/SupportedContentTest.kt new file mode 100644 index 0000000000..3219cde5ba --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/SupportedContentTest.kt @@ -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.ui.screen.loggedIn.video.dal + +import junit.framework.TestCase.assertTrue +import org.junit.Test + +class SupportedContentTest { + @Test + fun acceptableUrl() { + val supportedExtensions = setOf(".mp4", ".webm", ".jpg", ".png") + val mimeTypes = setOf("video/mp4", "video/webm", "image/jpeg", "image/png") + val blockedUrls = listOf("youtube.com", "youtu.be") + + val testVideoUrl = "https://example.com/video.mp4" + val blockedUrl = "https://youtube.com/watch?v=example" + val urlWithQuery = "https://example.com/media.jpg?param=1" + val urlWithFragment = "https://example.com/data.png#section" + + val contentSupport = SupportedContent(blockedUrls, mimeTypes, supportedExtensions) + + // Valid scenarios + assertTrue(contentSupport.acceptableUrl(testVideoUrl, "video/mp4")) // Valid extension and mime + assertTrue(contentSupport.acceptableUrl(urlWithQuery, "image/jpeg")) // Valid query param extension + assertTrue(contentSupport.acceptableUrl(urlWithFragment, "image/png")) // Valid fragment extension + + assertTrue(contentSupport.acceptableUrl(testVideoUrl, null)) + assertTrue(contentSupport.acceptableUrl(urlWithQuery, null)) + assertTrue(contentSupport.acceptableUrl(urlWithFragment, null)) + + // Blocked URL scenarios + assertTrue(!contentSupport.acceptableUrl(blockedUrl, null)) // Blocked URL + + // Invalid scenarios + assertTrue(!contentSupport.acceptableUrl("https://example.com/file.docx", "application/docx")) // Unsupported extension/mime + assertTrue(!contentSupport.acceptableUrl("https://example.com/file.docx", null)) // Unsupported extension/mime + } +} diff --git a/ammolite/build.gradle b/ammolite/build.gradle index ecd515719d..ec74b88080 100644 --- a/ammolite/build.gradle +++ b/ammolite/build.gradle @@ -6,12 +6,12 @@ plugins { } android { - namespace 'com.vitorpamplona.ammolite' - compileSdk libs.versions.android.compileSdk.get().toInteger() + namespace = 'com.vitorpamplona.ammolite' + compileSdk = libs.versions.android.compileSdk.get().toInteger() defaultConfig { - minSdk libs.versions.android.minSdk.get().toInteger() - targetSdk libs.versions.android.targetSdk.get().toInteger() + minSdk = libs.versions.android.minSdk.get().toInteger() + targetSdk = libs.versions.android.targetSdk.get().toInteger() testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" @@ -23,12 +23,12 @@ android { buildTypes { release { - minifyEnabled true + minifyEnabled = true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } create("benchmark") { initWith(getByName("release")) - signingConfig signingConfigs.debug + signingConfig = signingConfigs.debug } } compileOptions { diff --git a/ammolite/src/main/AndroidManifest.xml b/ammolite/src/main/AndroidManifest.xml index a5918e68ab..44008a4332 100644 --- a/ammolite/src/main/AndroidManifest.xml +++ b/ammolite/src/main/AndroidManifest.xml @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/BundledUpdate.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/BundledUpdate.kt index 0a26b050b1..2866251b24 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/BundledUpdate.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/BundledUpdate.kt @@ -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,9 +20,11 @@ */ package com.vitorpamplona.ammolite.relays +import android.util.Log import androidx.compose.runtime.Stable import com.vitorpamplona.ammolite.service.checkNotInMainThread import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable @@ -40,7 +42,13 @@ class BundledUpdate( val delay: Long, val dispatcher: CoroutineDispatcher = Dispatchers.Default, ) { - val scope = CoroutineScope(dispatcher + SupervisorJob()) + // Exists to avoid exceptions stopping the coroutine + val exceptionHandler = + CoroutineExceptionHandler { _, throwable -> + Log.e("BundledUpdate", "Caught exception: ${throwable.message}", throwable) + } + + val scope = CoroutineScope(dispatcher + SupervisorJob() + exceptionHandler) private var onlyOneInBlock = AtomicBoolean() private var invalidatesAgain = false @@ -83,7 +91,13 @@ class BundledInsert( val delay: Long, val dispatcher: CoroutineDispatcher = Dispatchers.Default, ) { - val scope = CoroutineScope(dispatcher + SupervisorJob()) + // Exists to avoid exceptions stopping the coroutine + val exceptionHandler = + CoroutineExceptionHandler { _, throwable -> + Log.e("BundledInsert", "Caught exception: ${throwable.message}", throwable) + } + + val scope = CoroutineScope(dispatcher + SupervisorJob() + exceptionHandler) private var onlyOneInBlock = AtomicBoolean() private var queue = LinkedBlockingQueue() diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Constants.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Constants.kt deleted file mode 100644 index 03a4ab3bd8..0000000000 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Constants.kt +++ /dev/null @@ -1,57 +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.ammolite.relays - -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter - -object Constants { - val activeTypesFollows = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS) - val activeTypesChats = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS) - val activeTypesGlobalChats = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL) - val activeTypesSearch = setOf(FeedType.SEARCH) - - val defaultRelays = - arrayOf( - // Free relays for only DMs, Chats and Follows due to the amount of spam - RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.bitcoiner.social"), read = true, write = true, feedTypes = activeTypesChats), - RelaySetupInfo(RelayUrlFormatter.normalize("wss://relay.nostr.bg"), read = true, write = true, feedTypes = activeTypesChats), - RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.oxtr.dev"), read = true, write = true, feedTypes = activeTypesChats), - RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.fmt.wiz.biz"), read = true, write = false, feedTypes = activeTypesChats), - RelaySetupInfo(RelayUrlFormatter.normalize("wss://relay.damus.io"), read = true, write = true, feedTypes = activeTypesFollows), - // Global - RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.mom"), read = true, write = true, feedTypes = activeTypesGlobalChats), - RelaySetupInfo(RelayUrlFormatter.normalize("wss://nos.lol"), read = true, write = true, feedTypes = activeTypesGlobalChats), - // Paid relays - RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostrelites.org"), read = true, write = false, feedTypes = activeTypesGlobalChats), - RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.wine"), read = true, write = false, feedTypes = activeTypesGlobalChats), - // Supporting NIP-50 - RelaySetupInfo(RelayUrlFormatter.normalize("wss://relay.nostr.band"), read = true, write = false, feedTypes = activeTypesSearch), - RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.wine"), read = true, write = false, feedTypes = activeTypesSearch), - RelaySetupInfo(RelayUrlFormatter.normalize("wss://relay.noswhere.com"), read = true, write = false, feedTypes = activeTypesSearch), - ) - - val defaultSearchRelaySet = - setOf( - RelayUrlFormatter.normalize("wss://relay.nostr.band"), - RelayUrlFormatter.normalize("wss://nostr.wine"), - RelayUrlFormatter.normalize("wss://relay.noswhere.com"), - ) -} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrClient.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrClient.kt deleted file mode 100644 index 2631384b55..0000000000 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrClient.kt +++ /dev/null @@ -1,489 +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.ammolite.relays - -import android.system.Os.close -import android.util.Log -import androidx.core.app.PendingIntentCompat.send -import com.vitorpamplona.ammolite.service.checkNotInMainThread -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relay.RelayState -import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilderFactory -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import java.util.UUID -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit - -/** - * The Nostr Client manages multiple personae the user may switch between. Events are received and - * published through multiple relays. Events are stored with their respective persona. - */ -class NostrClient( - private val websocketBuilder: WebsocketBuilderFactory, -) : RelayPool.Listener { - private val relayPool: RelayPool = RelayPool() - private val subscriptions: MutableSubscriptionManager = MutableSubscriptionManager() - - private var listeners = setOf() - private var relays = emptyArray() - - fun buildRelay(it: RelaySetupInfoToConnect): Relay = Relay(it.url, it.read, it.write, it.forceProxy, it.feedTypes, websocketBuilder, subscriptions) - - fun getRelay(url: String): Relay? = relayPool.getRelay(url) - - fun reconnect() { - // Reconnects all relays that may have disconnected - relayPool.requestAndWatch() - } - - @Synchronized - fun reconnect( - relays: Array?, - onlyIfChanged: Boolean = false, - ) { - Log.d("Relay", "Relay Pool Reconnecting to ${relays?.size} relays: \n${relays?.joinToString("\n") { it.url + " " + it.forceProxy + " " + it.read + " " + it.write + " " + it.feedTypes.joinToString(",") { it.name } }}") - checkNotInMainThread() - - if (onlyIfChanged) { - if (!isSameRelaySetConfig(relays)) { - if (this.relays.isNotEmpty()) { - relayPool.disconnect() - relayPool.unregister(this) - relayPool.unloadRelays() - } - - if (relays != null) { - val newRelays = relays.map(::buildRelay) - relayPool.register(this) - relayPool.loadRelays(newRelays) - relayPool.requestAndWatch() - this.relays = newRelays.toTypedArray() - } - } else { - // Reconnects all relays that may have disconnected - relayPool.requestAndWatch() - } - } else { - if (this.relays.isNotEmpty()) { - relayPool.disconnect() - relayPool.unregister(this) - relayPool.unloadRelays() - } - - if (relays != null) { - val newRelays = relays.map(::buildRelay) - relayPool.register(this) - relayPool.loadRelays(newRelays) - relayPool.requestAndWatch() - this.relays = newRelays.toTypedArray() - } - } - } - - fun isSameRelaySetConfig(newRelayConfig: Array?): Boolean { - if (relays.size != newRelayConfig?.size) return false - - relays.forEach { oldRelayInfo -> - val newRelayInfo = newRelayConfig.find { it.url == oldRelayInfo.url } ?: return false - - if (!oldRelayInfo.isSameRelayConfig(newRelayInfo)) return false - } - - return true - } - - fun sendFilter( - subscriptionId: String = UUID.randomUUID().toString().substring(0..10), - filters: List = listOf(), - ) { - checkNotInMainThread() - - subscriptions.add(subscriptionId, filters) - relayPool.sendFilter(subscriptionId, filters) - } - - fun sendFilterAndStopOnFirstResponse( - subscriptionId: String = UUID.randomUUID().toString().substring(0..10), - filters: List = listOf(), - onResponse: (Event) -> Unit, - ) { - checkNotInMainThread() - - subscribe( - object : Listener { - override fun onEvent( - event: Event, - subId: String, - relay: Relay, - afterEOSE: Boolean, - ) { - if (subId == subscriptionId) { - onResponse(event) - unsubscribe(this) - close(subscriptionId) - } - } - }, - ) - - subscriptions.add(subscriptionId, filters) - relayPool.sendFilter(subscriptionId, filters) - } - - @OptIn(DelicateCoroutinesApi::class) - suspend fun sendAndWaitForResponse( - signedEvent: Event, - relay: String? = null, - forceProxy: Boolean = false, - feedTypes: Set? = null, - relayList: List? = null, - onDone: (() -> Unit)? = null, - additionalListener: Listener? = null, - timeoutInSeconds: Long = 15, - ): Boolean { - checkNotInMainThread() - - val size = if (relay != null) 1 else relayList?.size ?: relayPool.availableRelays() - val latch = CountDownLatch(size) - val relayErrors = mutableMapOf() - var result = false - - Log.d("sendAndWaitForResponse", "Waiting for $size responses") - - val subscription = - object : Listener { - override fun onError( - error: Error, - subscriptionId: String, - relay: Relay, - ) { - relayErrors[relay.url]?.let { - latch.countDown() - } - Log.d("sendAndWaitForResponse", "onError Error from relay ${relay.url} count: ${latch.count} error: $error") - } - - override fun onEOSE( - relay: Relay, - subscriptionId: String, - ) { - latch.countDown() - Log.d("sendAndWaitForResponse", "onEOSE relay ${relay.url} count: ${latch.count}") - } - - override fun onRelayStateChange( - type: RelayState, - relay: Relay, - ) { - if (type == RelayState.DISCONNECTED) { - latch.countDown() - } - if (type == RelayState.CONNECTED) { - Log.d("sendAndWaitForResponse", "${type.name} Sending event to relay ${relay.url} count: ${latch.count}") - relay.send(signedEvent) - } - Log.d("sendAndWaitForResponse", "onRelayStateChange ${type.name} from relay ${relay.url} count: ${latch.count}") - } - - override fun onSendResponse( - eventId: String, - success: Boolean, - message: String, - relay: Relay, - ) { - if (eventId == signedEvent.id) { - if (success) { - result = true - } - latch.countDown() - Log.d("sendAndWaitForResponse", "onSendResponse Received response for $eventId from relay ${relay.url} count: ${latch.count} message $message success $success") - } - } - } - - subscribe(subscription) - additionalListener?.let { subscribe(it) } - - val job = - GlobalScope.launch(Dispatchers.IO) { - if (relayList != null) { - send(signedEvent, relayList) - } else if (relay == null) { - send(signedEvent) - } else { - sendSingle(signedEvent, RelaySetupInfoToConnect(relay, forceProxy, true, true, emptySet()), onDone ?: {}) - } - } - job.join() - - runBlocking { - latch.await(timeoutInSeconds, TimeUnit.SECONDS) - } - Log.d("sendAndWaitForResponse", "countdown finished") - unsubscribe(subscription) - additionalListener?.let { unsubscribe(it) } - return result - } - - fun getAll(): List = relayPool.getAll() - - fun sendFilterOnlyIfDisconnected( - subscriptionId: String = UUID.randomUUID().toString().substring(0..10), - filters: List = listOf(), - ) { - checkNotInMainThread() - - subscriptions.add(subscriptionId, filters) - relayPool.connectAndSendFiltersIfDisconnected() - } - - fun sendIfExists( - signedEvent: Event, - connectedRelay: Relay, - ) { - checkNotInMainThread() - - relayPool.getRelays(connectedRelay.url).forEach { - it.send(signedEvent) - } - } - - fun sendSingle( - signedEvent: Event, - relayTemplate: RelaySetupInfoToConnect, - onDone: (() -> Unit), - ) { - checkNotInMainThread() - - relayPool.runCreatingIfNeeded(buildRelay(relayTemplate), onDone = onDone) { - it.send(signedEvent) - } - } - - fun send(signedEvent: Event) { - checkNotInMainThread() - relayPool.send(signedEvent) - } - - fun send( - signedEvent: Event, - relayList: List, - ) { - checkNotInMainThread() - - relayPool.sendToSelectedRelays(relayList, signedEvent) - } - - fun sendPrivately( - signedEvent: Event, - relayList: List, - ) { - checkNotInMainThread() - - relayList.forEach { relayTemplate -> - relayPool.runCreatingIfNeeded(buildRelay(relayTemplate)) { - it.sendOverride(signedEvent) - } - } - } - - fun close(subscriptionId: String) { - relayPool.close(subscriptionId) - subscriptions.remove(subscriptionId) - } - - fun isActive(subscriptionId: String): Boolean = subscriptions.isActive(subscriptionId) - - @OptIn(DelicateCoroutinesApi::class) - override fun onEvent( - event: Event, - subscriptionId: String, - relay: Relay, - afterEOSE: Boolean, - ) { - // Releases the Web thread for the new payload. - // May need to add a processing queue if processing new events become too costly. - GlobalScope.launch(Dispatchers.Default) { - listeners.forEach { it.onEvent(event, subscriptionId, relay, afterEOSE) } - } - } - - override fun onEOSE( - relay: Relay, - subscriptionId: String, - ) { - listeners.forEach { it.onEOSE(relay, subscriptionId) } - } - - override fun onRelayStateChange( - type: RelayState, - relay: Relay, - ) { - listeners.forEach { it.onRelayStateChange(type, relay) } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun onSendResponse( - eventId: String, - success: Boolean, - message: String, - relay: Relay, - ) { - // Releases the Web thread for the new payload. - // May need to add a processing queue if processing new events become too costly. - GlobalScope.launch(Dispatchers.Default) { - listeners.forEach { it.onSendResponse(eventId, success, message, relay) } - } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun onAuth( - relay: Relay, - challenge: String, - ) { - // Releases the Web thread for the new payload. - // May need to add a processing queue if processing new events become too costly. - GlobalScope.launch(Dispatchers.Default) { listeners.forEach { it.onAuth(relay, challenge) } } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun onNotify( - relay: Relay, - description: String, - ) { - // Releases the Web thread for the new payload. - // May need to add a processing queue if processing new events become too costly. - GlobalScope.launch(Dispatchers.Default) { - listeners.forEach { it.onNotify(relay, description) } - } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun onSend( - relay: Relay, - msg: String, - success: Boolean, - ) { - GlobalScope.launch(Dispatchers.Default) { - listeners.forEach { it.onSend(relay, msg, success) } - } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun onBeforeSend( - relay: Relay, - event: Event, - ) { - GlobalScope.launch(Dispatchers.Default) { - listeners.forEach { it.onBeforeSend(relay, event) } - } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun onError( - error: Error, - subscriptionId: String, - relay: Relay, - ) { - GlobalScope.launch(Dispatchers.Default) { - listeners.forEach { it.onError(error, subscriptionId, relay) } - } - } - - fun subscribe(listener: Listener) { - listeners = listeners.plus(listener) - } - - fun isSubscribed(listener: Listener): Boolean = listeners.contains(listener) - - fun unsubscribe(listener: Listener) { - listeners = listeners.minus(listener) - } - - fun allSubscriptions(): Map> = subscriptions.allSubscriptions() - - fun getSubscriptionFilters(subId: String): List = subscriptions.getSubscriptionFilters(subId) - - fun connectedRelays() = relayPool.connectedRelays() - - fun relayStatusFlow() = relayPool.statusFlow - - interface Listener { - /** A new message was received */ - open fun onEvent( - event: Event, - subscriptionId: String, - relay: Relay, - afterEOSE: Boolean, - ) = Unit - - /** Connected to or disconnected from a relay */ - open fun onEOSE( - relay: Relay, - subscriptionId: String, - ) = Unit - - /** Connected to or disconnected from a relay */ - open fun onRelayStateChange( - type: RelayState, - relay: Relay, - ) = Unit - - /** When an relay saves or rejects a new event. */ - open fun onSendResponse( - eventId: String, - success: Boolean, - message: String, - relay: Relay, - ) = Unit - - open fun onAuth( - relay: Relay, - challenge: String, - ) = Unit - - open fun onNotify( - relay: Relay, - description: String, - ) = Unit - - open fun onSend( - relay: Relay, - msg: String, - success: Boolean, - ) = Unit - - open fun onBeforeSend( - relay: Relay, - event: Event, - ) = Unit - - open fun onError( - error: Error, - subscriptionId: String, - relay: Relay, - ) = Unit - } -} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrDataSource.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrDataSource.kt deleted file mode 100644 index f585f438f3..0000000000 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrDataSource.kt +++ /dev/null @@ -1,314 +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.ammolite.relays - -import android.util.Log -import com.vitorpamplona.ammolite.service.checkNotInMainThread -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relay.RelayState -import com.vitorpamplona.quartz.utils.TimeUtils -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import java.util.UUID -import java.util.concurrent.atomic.AtomicBoolean - -abstract class NostrDataSource( - val client: NostrClient, - val debugName: String, -) { - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - - private var subscriptions = mapOf() - - data class Counter( - val subscriptionId: String, - val eventKind: Int, - var counter: Int, - ) - - private var eventCounter = mapOf() - var changingFilters = AtomicBoolean() - - private var active: Boolean = false - - fun printCounter() { - eventCounter.forEach { - Log.d( - "STATE DUMP ${this.javaClass.simpleName}", - "Received Events $debugName ${it.value.subscriptionId} ${it.value.eventKind}: ${it.value.counter}", - ) - } - } - - fun hashCodeFields( - str1: String, - str2: Int, - ): Int = 31 * str1.hashCode() + str2.hashCode() - - private val clientListener = - object : NostrClient.Listener { - override fun onEvent( - event: Event, - subscriptionId: String, - relay: Relay, - afterEOSE: Boolean, - ) { - if (subscriptions.containsKey(subscriptionId)) { - val key = hashCodeFields(subscriptionId, event.kind) - val keyValue = eventCounter[key] - if (keyValue != null) { - keyValue.counter++ - } else { - eventCounter = eventCounter + Pair(key, Counter(subscriptionId, event.kind, 1)) - } - - // Log.d(this@NostrDataSource.javaClass.simpleName, "Relay ${relay.url}: $subscriptionId ${event.kind} ") - - consume(event, relay) - if (afterEOSE) { - markAsEOSE(subscriptionId, relay) - } - } - } - - override fun onEOSE( - relay: Relay, - subscriptionId: String, - ) { - if (subscriptions.containsKey(subscriptionId)) { - markAsEOSE(subscriptionId, relay) - } - } - - override fun onRelayStateChange( - type: RelayState, - relay: Relay, - ) {} - - override fun onSendResponse( - eventId: String, - success: Boolean, - message: String, - relay: Relay, - ) { - if (success) { - markAsSeenOnRelay(eventId, relay) - } - } - - override fun onAuth( - relay: Relay, - challenge: String, - ) { - auth(relay, challenge) - } - - override fun onNotify( - relay: Relay, - description: String, - ) { - notify(relay, description) - } - } - - init { - Log.d("DataSource", "${this.javaClass.simpleName} Subscribe") - client.subscribe(clientListener) - } - - fun destroy() { - // makes sure to run - Log.d("DataSource", "${this.javaClass.simpleName} Unsubscribe") - stop() - client.unsubscribe(clientListener) - scope.cancel() - bundler.cancel() - } - - open fun start() { - Log.d("DataSource", "${this.javaClass.simpleName} Start") - active = true - resetFilters() - } - - open fun startSync() { - Log.d("DataSource", "${this.javaClass.simpleName} Start") - active = true - resetFiltersSuspend() - } - - @OptIn(DelicateCoroutinesApi::class) - open fun stop() { - active = false - Log.d("DataSource", "${this.javaClass.simpleName} Stop") - - GlobalScope.launch(Dispatchers.IO) { - subscriptions.values.forEach { subscription -> - client.close(subscription.id) - subscription.typedFilters = null - } - } - } - - open fun stopSync() { - active = false - Log.d("DataSource", "${this.javaClass.simpleName} Stop") - - subscriptions.values.forEach { subscription -> - client.close(subscription.id) - subscription.typedFilters = null - } - } - - fun requestNewChannel(onEOSE: ((Long, String) -> Unit)? = null): Subscription { - val newSubscription = Subscription(UUID.randomUUID().toString().substring(0, 4), onEOSE) - subscriptions = subscriptions + Pair(newSubscription.id, newSubscription) - return newSubscription - } - - fun dismissChannel(subscription: Subscription) { - client.close(subscription.id) - subscriptions = subscriptions.minus(subscription.id) - } - - // Refreshes observers in batches. - private val bundler = BundledUpdate(300, Dispatchers.Default) - - fun invalidateFilters() { - bundler.invalidate { - // println("DataSource: ${this.javaClass.simpleName} InvalidateFilters") - - // adds the time to perform the refresh into this delay - // holding off new updates in case of heavy refresh routines. - resetFiltersSuspend() - } - } - - fun resetFilters() { - scope.launch(Dispatchers.IO) { resetFiltersSuspend() } - } - - private fun resetFiltersSuspend() { - Log.d("DataSource", "${this.javaClass.simpleName} resetFiltersSuspend $active") - checkNotInMainThread() - - // saves the channels that are currently active - val activeSubscriptions = subscriptions.values.filter { it.typedFilters != null } - // saves the current content to only update if it changes - val currentFilters = activeSubscriptions.associate { it.id to it.typedFilters } - - changingFilters.getAndSet(true) - - updateChannelFilters() - - // Makes sure to only send an updated filter when it actually changes. - subscriptions.values.forEach { updatedSubscription -> - val updatedSubscriptionNewFilters = updatedSubscription.typedFilters - - val isActive = client.isActive(updatedSubscription.id) - - if (!isActive && updatedSubscriptionNewFilters != null) { - // Filter was removed from the active list - if (active) { - client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) - } - } else { - if (currentFilters.containsKey(updatedSubscription.id)) { - if (updatedSubscriptionNewFilters == null) { - // was active and is not active anymore, just close. - client.close(updatedSubscription.id) - } else { - // was active and is still active, check if it has changed. - if (updatedSubscription.hasChangedFiltersFrom(currentFilters[updatedSubscription.id])) { - client.close(updatedSubscription.id) - if (active) { - client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) - } - } else { - // hasn't changed, does nothing. - if (active) { - client.sendFilterOnlyIfDisconnected( - updatedSubscription.id, - updatedSubscriptionNewFilters, - ) - } - } - } - } else { - if (updatedSubscriptionNewFilters == null) { - // was not active and is still not active, does nothing - } else { - // was not active and becomes active, sends the filter. - if (updatedSubscription.hasChangedFiltersFrom(currentFilters[updatedSubscription.id])) { - if (active) { - Log.d( - this@NostrDataSource.javaClass.simpleName, - "Update Filter 3 ${updatedSubscription.id} ${client.isSubscribed(clientListener)}", - ) - client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) - } - } - } - } - } - } - - changingFilters.getAndSet(false) - } - - open fun consume( - event: Event, - relay: Relay, - ) = Unit - - open fun markAsSeenOnRelay( - eventId: String, - relay: Relay, - ) = Unit - - open fun markAsEOSE( - subscriptionId: String, - relay: Relay, - ) { - subscriptions[subscriptionId]?.updateEOSE( - // in case people's clock is slighly off. - TimeUtils.oneMinuteAgo(), - relay.url, - ) - } - - abstract fun updateChannelFilters() - - open fun auth( - relay: Relay, - challenge: String, - ) = Unit - - open fun notify( - relay: Relay, - description: String, - ) = Unit -} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt deleted file mode 100644 index 09ce54737c..0000000000 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt +++ /dev/null @@ -1,271 +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.ammolite.relays - -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relay.RelayState -import com.vitorpamplona.quartz.nip01Core.relay.SimpleClientRelay -import com.vitorpamplona.quartz.nip01Core.relay.SubscriptionCollection -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilderFactory -import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent - -enum class FeedType { - FOLLOWS, - PUBLIC_CHATS, - PRIVATE_DMS, - GLOBAL, - SEARCH, - WALLET_CONNECT, -} - -val ALL_FEED_TYPES = - setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH) - -val COMMON_FEED_TYPES = - setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL) - -val EVENT_FINDER_TYPES = - setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL) - -class RelaySubFilter( - val url: String, - val activeTypes: Set, - val subs: SubscriptionManager, -) : SubscriptionCollection { - fun isMatch(filter: TypedFilter) = activeTypes.any { it in filter.types } && filter.filter.isValidFor(url) - - fun match(filters: List): Boolean = - filters.any { filter -> - isMatch(filter) - } - - override fun isActive(subscriptionId: String): Boolean = subs.isActive(subscriptionId) && match(subs.getSubscriptionFilters(subscriptionId)) - - override fun getFilters(subscriptionId: String) = filter(subs.getSubscriptionFilters(subscriptionId)) - - override fun allSubscriptions(): List = - subs.allSubscriptions().mapNotNull { filter -> - val filters = filter(filter.value) - if (filters.isNotEmpty()) { - com.vitorpamplona.quartz.nip01Core.relay - .Subscription(filter.key, filters) - } else { - null - } - } - - override fun match( - subscriptionId: String, - event: Event, - ): Boolean = subs.getSubscriptionFilters(subscriptionId).any { it.filter.match(event, url) } - - fun filter(filters: List): List = - filters.mapNotNull { filter -> - if (isMatch(filter)) { - filter.filter.toRelay(url) - } else { - null - } - } -} - -class Relay( - val url: String, - val read: Boolean = true, - val write: Boolean = true, - val forceProxy: Boolean = false, - val activeTypes: Set, - socketBuilderFactory: WebsocketBuilderFactory, - subs: SubscriptionManager, -) : SimpleClientRelay.Listener { - private var listeners = setOf() - - val relaySubFilter = RelaySubFilter(url, activeTypes, subs) - val inner = - SimpleClientRelay(url, socketBuilderFactory.build(url, forceProxy), relaySubFilter, this@Relay, RelayStats.get(url)) - - val brief = RelayBriefInfoCache.get(url) - - fun register(listener: Listener) { - listeners = listeners.plus(listener) - } - - fun unregister(listener: Listener) { - listeners = listeners.minus(listener) - } - - fun isConnected() = inner.isConnected() - - fun connect() = inner.connect() - - fun connectAndRunAfterSync(onConnected: () -> Unit) { - // BRB is crashing OkHttp Deflater object :( - if (url.contains("brb.io")) return - - inner.connectAndRunAfterSync(onConnected) - } - - fun sendOutbox() = inner.sendOutbox() - - fun disconnect() = inner.disconnect() - - fun sendFilter( - requestId: String, - filters: List, - ) { - if (read) { - inner.sendRequest(requestId, relaySubFilter.filter(filters)) - } - } - - fun connectAndSendFiltersIfDisconnected() = inner.connectAndSendFiltersIfDisconnected() - - fun renewFilters() = inner.renewSubscriptions() - - fun sendOverride(signedEvent: Event) = inner.send(signedEvent) - - fun send(signedEvent: Event) { - if (signedEvent is RelayAuthEvent || write) { - inner.send(signedEvent) - } - } - - fun close(subscriptionId: String) = inner.close(subscriptionId) - - fun isSameRelayConfig(other: RelaySetupInfoToConnect): Boolean = - url == other.url && - forceProxy == other.forceProxy && - write == other.write && - read == other.read && - activeTypes == other.feedTypes - - override fun onEvent( - relay: SimpleClientRelay, - subscriptionId: String, - event: Event, - afterEOSE: Boolean, - ) = listeners.forEach { it.onEvent(this, subscriptionId, event, afterEOSE) } - - override fun onError( - relay: SimpleClientRelay, - subscriptionId: String, - error: Error, - ) = listeners.forEach { it.onError(this, subscriptionId, error) } - - override fun onEOSE( - relay: SimpleClientRelay, - subscriptionId: String, - ) = listeners.forEach { it.onEOSE(this, subscriptionId) } - - override fun onRelayStateChange( - relay: SimpleClientRelay, - type: RelayState, - ) = listeners.forEach { it.onRelayStateChange(this, type) } - - override fun onSendResponse( - relay: SimpleClientRelay, - eventId: String, - success: Boolean, - message: String, - ) = listeners.forEach { it.onSendResponse(this, eventId, success, message) } - - override fun onAuth( - relay: SimpleClientRelay, - challenge: String, - ) = listeners.forEach { it.onAuth(this, challenge) } - - override fun onNotify( - relay: SimpleClientRelay, - description: String, - ) = listeners.forEach { it.onNotify(this, description) } - - override fun onClosed( - relay: SimpleClientRelay, - subscriptionId: String, - message: String, - ) { } - - override fun onSend( - relay: SimpleClientRelay, - msg: String, - success: Boolean, - ) = listeners.forEach { it.onSend(this, msg, success) } - - override fun onBeforeSend( - relay: SimpleClientRelay, - event: Event, - ) = listeners.forEach { it.onBeforeSend(this, event) } - - interface Listener { - fun onEvent( - relay: Relay, - subscriptionId: String, - event: Event, - afterEOSE: Boolean, - ) - - fun onEOSE( - relay: Relay, - subscriptionId: String, - ) - - fun onError( - relay: Relay, - subscriptionId: String, - error: Error, - ) - - fun onSendResponse( - relay: Relay, - eventId: String, - success: Boolean, - message: String, - ) - - fun onAuth( - relay: Relay, - challenge: String, - ) - - fun onRelayStateChange( - relay: Relay, - type: RelayState, - ) - - /** Relay sent a notification */ - fun onNotify( - relay: Relay, - description: String, - ) - - fun onBeforeSend( - relay: Relay, - event: Event, - ) - - fun onSend( - relay: Relay, - msg: String, - success: Boolean, - ) - } -} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayPool.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayPool.kt deleted file mode 100644 index 303940f938..0000000000 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayPool.kt +++ /dev/null @@ -1,305 +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.ammolite.relays - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.ammolite.service.checkNotInMainThread -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relay.RelayState -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.launch - -/** - * RelayPool manages the connection to multiple Relays and lets consumers deal with simple events. - */ -class RelayPool : Relay.Listener { - private var relays = listOf() - private var listeners = setOf() - - // Backing property to avoid flow emissions from other classes - private var lastStatus = RelayPoolStatus(0, 0) - private val _statusFlow = - MutableSharedFlow(1, 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) - val statusFlow: SharedFlow = _statusFlow.asSharedFlow() - - fun availableRelays(): Int = relays.size - - fun connectedRelays(): Int = relays.count { it.isConnected() } - - fun getRelay(url: String): Relay? = relays.firstOrNull { it.url == url } - - fun getRelays(url: String): List = relays.filter { it.url == url } - - fun getAll() = relays - - fun runCreatingIfNeeded( - relay: Relay, - timeout: Long = 60000, - onDone: (() -> Unit)? = null, - whenConnected: (Relay) -> Unit, - ) { - synchronized(this) { - val matching = getRelays(relay.url) - if (matching.isNotEmpty()) { - matching.forEach { whenConnected(it) } - } else { - addRelay(relay) - - relay.connectAndRunAfterSync { - whenConnected(relay) - - GlobalScope.launch(Dispatchers.IO) { - delay(timeout) // waits for a reply - relay.disconnect() - removeRelay(relay) - - if (onDone != null) { - onDone() - } - } - } - } - } - } - - fun loadRelays(relayList: List) { - check(relayList.isNotEmpty()) { "Relay list should never be empty" } - relayList.forEach { addRelayInner(it) } - updateStatus() - } - - fun unloadRelays() { - relays.forEach { it.unregister(this) } - relays = listOf() - } - - fun requestAndWatch() { - checkNotInMainThread() - - relays.forEach { it.connect() } - } - - fun sendFilter( - subscriptionId: String, - filters: List, - ) { - relays.forEach { relay -> - relay.sendFilter(subscriptionId, filters) - } - } - - fun connectAndSendFiltersIfDisconnected() { - relays.forEach { it.connectAndSendFiltersIfDisconnected() } - } - - fun sendToSelectedRelays( - list: List, - signedEvent: Event, - ) { - list.forEach { relay -> relays.filter { it.url == relay.url }.forEach { it.sendOverride(signedEvent) } } - } - - fun send(signedEvent: Event) { - relays.forEach { it.send(signedEvent) } - } - - fun sendOverride(signedEvent: Event) { - relays.forEach { it.sendOverride(signedEvent) } - } - - fun close(subscriptionId: String) { - relays.forEach { it.close(subscriptionId) } - } - - fun disconnect() { - relays.forEach { it.disconnect() } - } - - fun addRelay(relay: Relay) { - addRelayInner(relay) - updateStatus() - } - - private fun addRelayInner(relay: Relay) { - relay.register(this) - relays += relay - } - - fun removeRelay(relay: Relay) { - relay.unregister(this) - relays = relays.minus(relay) - updateStatus() - } - - fun register(listener: Listener) { - listeners = listeners.plus(listener) - } - - fun unregister(listener: Listener) { - listeners = listeners.minus(listener) - } - - interface Listener { - fun onEvent( - event: Event, - subscriptionId: String, - relay: Relay, - afterEOSE: Boolean, - ) - - fun onEOSE( - relay: Relay, - subscriptionId: String, - ) - - fun onRelayStateChange( - type: RelayState, - relay: Relay, - ) - - fun onSendResponse( - eventId: String, - success: Boolean, - message: String, - relay: Relay, - ) - - fun onAuth( - relay: Relay, - challenge: String, - ) - - fun onNotify( - relay: Relay, - description: String, - ) - - fun onSend( - relay: Relay, - msg: String, - success: Boolean, - ) - - fun onBeforeSend( - relay: Relay, - event: Event, - ) - - fun onError( - error: Error, - subscriptionId: String, - relay: Relay, - ) - } - - override fun onEvent( - relay: Relay, - subscriptionId: String, - event: Event, - afterEOSE: Boolean, - ) { - listeners.forEach { it.onEvent(event, subscriptionId, relay, afterEOSE) } - } - - override fun onError( - relay: Relay, - subscriptionId: String, - error: Error, - ) { - listeners.forEach { it.onError(error, subscriptionId, relay) } - updateStatus() - } - - override fun onEOSE( - relay: Relay, - subscriptionId: String, - ) { - listeners.forEach { it.onEOSE(relay, subscriptionId) } - updateStatus() - } - - override fun onRelayStateChange( - relay: Relay, - type: RelayState, - ) { - listeners.forEach { it.onRelayStateChange(type, relay) } - } - - override fun onSendResponse( - relay: Relay, - eventId: String, - success: Boolean, - message: String, - ) { - listeners.forEach { it.onSendResponse(eventId, success, message, relay) } - } - - override fun onAuth( - relay: Relay, - challenge: String, - ) { - listeners.forEach { it.onAuth(relay, challenge) } - } - - override fun onNotify( - relay: Relay, - description: String, - ) { - listeners.forEach { it.onNotify(relay, description) } - } - - override fun onSend( - relay: Relay, - msg: String, - success: Boolean, - ) { - listeners.forEach { it.onSend(relay, msg, success) } - } - - override fun onBeforeSend( - relay: Relay, - event: Event, - ) { - listeners.forEach { it.onBeforeSend(relay, event) } - } - - private fun updateStatus() { - val connected = connectedRelays() - val available = availableRelays() - if (lastStatus.connected != connected || lastStatus.available != available) { - lastStatus = RelayPoolStatus(connected, available) - _statusFlow.tryEmit(lastStatus) - } - } -} - -@Immutable -data class RelayPoolStatus( - val connected: Int, - val available: Int, - val isConnected: Boolean = connected > 0, -) diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Subscription.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Subscription.kt deleted file mode 100644 index 9a4f8facda..0000000000 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Subscription.kt +++ /dev/null @@ -1,119 +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.ammolite.relays - -import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import java.util.UUID - -data class Subscription( - val id: String = UUID.randomUUID().toString().substring(0, 4), - val onEOSE: ((Long, String) -> Unit)? = null, -) { - var typedFilters: List? = null // Inactive when null - - fun updateEOSE( - time: Long, - relay: String, - ) { - onEOSE?.let { it(time, relay) } - } - - fun hasChangedFiltersFrom(otherFilters: List?): Boolean { - if (typedFilters == null && otherFilters == null) return false - if (typedFilters?.size != otherFilters?.size) return true - - typedFilters?.forEachIndexed { index, typedFilter -> - val otherFilter = otherFilters?.getOrNull(index) ?: return true - - if (typedFilter.filter is SincePerRelayFilter && otherFilter.filter is SincePerRelayFilter) { - return isDifferent(typedFilter.filter, otherFilter.filter) - } - - if (typedFilter.filter is SinceAuthorPerRelayFilter && otherFilter.filter is SinceAuthorPerRelayFilter) { - return isDifferent(typedFilter.filter, otherFilter.filter) - } - - return true - } - return false - } - - fun isDifferent( - filter1: SincePerRelayFilter, - filter2: SincePerRelayFilter, - ): Boolean { - // Does not check SINCE on purpose. Avoids replacing the filter if SINCE was all that changed. - // fast check - if (filter1.authors?.size != filter2.authors?.size || - filter1.ids?.size != filter2.ids?.size || - filter1.tags?.size != filter2.tags?.size || - filter1.kinds?.size != filter2.kinds?.size || - filter1.limit != filter2.limit || - filter1.search?.length != filter2.search?.length || - filter1.until != filter2.until - ) { - return true - } - - // deep check - if (filter1.ids != filter2.ids || - filter1.authors != filter2.authors || - filter1.tags != filter2.tags || - filter1.kinds != filter2.kinds || - filter1.search != filter2.search - ) { - return true - } - - return false - } - - fun isDifferent( - filter1: SinceAuthorPerRelayFilter, - filter2: SinceAuthorPerRelayFilter, - ): Boolean { - // Does not check SINCE on purpose. Avoids replacing the filter if SINCE was all that changed. - // fast check - if (filter1.authors?.size != filter2.authors?.size || - filter1.ids?.size != filter2.ids?.size || - filter1.tags?.size != filter2.tags?.size || - filter1.kinds?.size != filter2.kinds?.size || - filter1.limit != filter2.limit || - filter1.search?.length != filter2.search?.length || - filter1.until != filter2.until - ) { - return true - } - - // deep check - if (filter1.ids != filter2.ids || - filter1.authors != filter2.authors || - filter1.tags != filter2.tags || - filter1.kinds != filter2.kinds || - filter1.search != filter2.search - ) { - return true - } - - return false - } -} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/MutableTime.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/MutableTime.kt new file mode 100644 index 0000000000..67dfd8f2f4 --- /dev/null +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/MutableTime.kt @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.ammolite.relays.filters + +/* +* Wrapper class to allow changing in EOSE without modifying the list it is included within +*/ +class MutableTime( + startTime: Long, +) { + var time: Long = startTime + private set + + override fun toString(): String = time.toString() + + fun updateIfNewer(newTime: Long) { + if (newTime > time) { + time = newTime + } + } + + fun updateIfOlder(newTime: Long) { + if (newTime < time) { + time = newTime + } + } + + fun minus(delta: Int) = MutableTime(time - delta) + + fun copy() = MutableTime(time) +} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SinceAuthorPerRelayFilter.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SinceAuthorPerRelayFilter.kt deleted file mode 100644 index 4a34dda591..0000000000 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SinceAuthorPerRelayFilter.kt +++ /dev/null @@ -1,82 +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.ammolite.relays.filters - -import com.fasterxml.jackson.databind.node.JsonNodeFactory -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterMatcher -import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterSerializer - -/** - * This is a nostr filter with per-relay authors list and since parameters - */ -class SinceAuthorPerRelayFilter( - val ids: List? = null, - val authors: Map>? = null, - val kinds: List? = null, - val tags: Map>? = null, - val since: Map? = null, - val until: Long? = null, - val limit: Int? = null, - val search: String? = null, -) : IPerRelayFilter { - // This only exists because some relays consider empty arrays as null and return everything. - // So, if there is an author list, but no list for the specific relay or if the list is empty - // don't send it. - override fun isValidFor(forRelay: String) = authors == null || !authors[forRelay].isNullOrEmpty() - - override fun toRelay(forRelay: String) = Filter(ids, authors?.get(forRelay), kinds, tags, since?.get(forRelay)?.time, until, limit, search) - - override fun toJson(forRelay: String): String = FilterSerializer.toJson(ids, authors?.get(forRelay), kinds, tags, since?.get(forRelay)?.time, until, limit, search) - - override fun match( - event: Event, - forRelay: String, - ) = FilterMatcher.match(event, ids, authors?.get(forRelay), kinds, tags, since?.get(forRelay)?.time, until) - - override fun toDebugJson(): String { - val factory = JsonNodeFactory.instance - val obj = FilterSerializer.toJsonObject(ids, null, kinds, tags, null, until, limit, search) - authors?.run { - if (isNotEmpty()) { - val jsonObjectPerRelayAuthors = factory.objectNode() - entries.forEach { relayAuthorPairs -> - jsonObjectPerRelayAuthors.put(relayAuthorPairs.key, factory.arrayNode(relayAuthorPairs.value.size).apply { relayAuthorPairs.value.forEach { add(it) } }) - } - obj.put("authors", jsonObjectPerRelayAuthors) - } - } - - since?.run { - if (isNotEmpty()) { - val jsonObjectSince = factory.objectNode() - entries.forEach { sincePairs -> - jsonObjectSince.put(sincePairs.key, "${sincePairs.value}") - } - obj.put("since", jsonObjectSince) - } - } - return EventMapper.mapper.writeValueAsString(obj) - } -} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SincePerRelayFilter.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SincePerRelayFilter.kt deleted file mode 100644 index a1ba84ae32..0000000000 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SincePerRelayFilter.kt +++ /dev/null @@ -1,69 +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.ammolite.relays.filters - -import com.fasterxml.jackson.databind.node.JsonNodeFactory -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterMatcher -import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterSerializer - -/** - * This is a nostr filter with per-relay authors list and since parameters - */ -class SincePerRelayFilter( - val ids: List? = null, - val authors: List? = null, - val kinds: List? = null, - val tags: Map>? = null, - val since: Map? = null, - val until: Long? = null, - val limit: Int? = null, - val search: String? = null, -) : IPerRelayFilter { - override fun isValidFor(url: String) = true - - override fun toRelay(forRelay: String) = Filter(ids, authors, kinds, tags, since?.get(forRelay)?.time, until, limit, search) - - override fun toJson(forRelay: String) = FilterSerializer.toJson(ids, authors, kinds, tags, since?.get(forRelay)?.time, until, limit, search) - - override fun match( - event: Event, - forRelay: String, - ) = FilterMatcher.match(event, ids, authors, kinds, tags, since?.get(forRelay)?.time, until) - - override fun toDebugJson(): String { - val factory = JsonNodeFactory.instance - val obj = FilterSerializer.toJsonObject(ids, authors, kinds, tags, null, until, limit, search) - - since?.run { - if (isNotEmpty()) { - val jsonObjectSince = factory.objectNode() - entries.forEach { sincePairs -> - jsonObjectSince.put(sincePairs.key, "${sincePairs.value}") - } - obj.put("since", jsonObjectSince) - } - } - return EventMapper.toJson(obj) - } -} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/service/MainThreadChecker.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/service/MainThreadChecker.kt index ff549f5e3d..afd0bf22ae 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/service/MainThreadChecker.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/service/MainThreadChecker.kt @@ -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 diff --git a/benchmark/build.gradle b/benchmark/build.gradle index 2f907d1718..a45aa4aaeb 100644 --- a/benchmark/build.gradle +++ b/benchmark/build.gradle @@ -7,12 +7,12 @@ plugins { } android { - namespace 'com.vitorpamplona.amethyst.benchmark' - compileSdk libs.versions.android.compileSdk.get().toInteger() + namespace = 'com.vitorpamplona.amethyst.benchmark' + compileSdk = libs.versions.android.compileSdk.get().toInteger() defaultConfig { - minSdk libs.versions.android.minSdk.get().toInteger() - targetSdk libs.versions.android.targetSdk.get().toInteger() + minSdk = libs.versions.android.minSdk.get().toInteger() + targetSdk = libs.versions.android.targetSdk.get().toInteger() // Enable measuring on an emulator, or devices with low battery testInstrumentationRunner 'androidx.benchmark.junit4.AndroidBenchmarkRunner' @@ -36,12 +36,12 @@ android { } release { isDefault = false - minifyEnabled true + minifyEnabled = true } create("benchmark") { isDefault = true initWith(getByName("release")) - signingConfig signingConfigs.debug + signingConfig = signingConfigs.debug } } } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/BlurhashBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/BlurhashBenchmark.kt index 8210d8ea16..2de17d7568 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/BlurhashBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/BlurhashBenchmark.kt @@ -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 diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/ExpandableTextCutOffCalculatorBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/ExpandableTextCutOffCalculatorBenchmark.kt index 0fa843a6df..b247661a47 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/ExpandableTextCutOffCalculatorBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/ExpandableTextCutOffCalculatorBenchmark.kt @@ -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 diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/MetaTagsParserBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/MetaTagsParserBenchmark.kt index 273c3d775e..6b6aea7d23 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/MetaTagsParserBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/MetaTagsParserBenchmark.kt @@ -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 diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RichTextParserBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RichTextParserBenchmark.kt index b46402730b..b7898ee416 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RichTextParserBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RichTextParserBenchmark.kt @@ -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 diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RobohashBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RobohashBenchmark.kt index 8ed326f866..cbb0250609 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RobohashBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RobohashBenchmark.kt @@ -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 diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RobohashPartsBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RobohashPartsBenchmark.kt index ee96bafcef..a2a5bcb5f3 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RobohashPartsBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RobohashPartsBenchmark.kt @@ -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 diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/BaseLargeCacheBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/BaseLargeCacheBenchmark.kt new file mode 100644 index 0000000000..3f502cb4db --- /dev/null +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/BaseLargeCacheBenchmark.kt @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.benchmark + +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper +import com.vitorpamplona.quartz.utils.LargeCache +import org.junit.Assert.assertTrue +import java.util.function.Consumer +import java.util.zip.GZIPInputStream + +open class BaseLargeCacheBenchmark { + companion object { + fun getEventDB(): List { + // This file includes duplicates + val fullDBInputStream = getInstrumentation().context.assets.open("nostr_vitor_startup_data.json") + + return JsonMapper.mapper.readValue>( + GZIPInputStream(fullDBInputStream), + ) + } + } + + fun getLargeCache(db: List): LargeCache { + val cache = LargeCache() + + db.forEach { event -> + cache.getOrCreate(event.id) { key -> event } + } + + return cache + } + + fun hasId(event: Event) { + assertTrue(event.id.isNotEmpty()) + } + + val consumer = + Consumer { + hasId(it) + } +} diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/BechBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/BechBenchmark.kt index 207509b881..14e8ca7c59 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/BechBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/BechBenchmark.kt @@ -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 diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/BloomFilterMurMur3Benchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/BloomFilterMurMur3Benchmark.kt index c4579f013e..e976dfac74 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/BloomFilterMurMur3Benchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/BloomFilterMurMur3Benchmark.kt @@ -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 diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/CacheBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/CacheBenchmark.kt index a20ce449e1..2e0bf231a4 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/CacheBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/CacheBenchmark.kt @@ -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,10 +25,10 @@ import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import com.fasterxml.jackson.module.kotlin.readValue -import com.vitorpamplona.amethyst.commons.data.LargeCache import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper +import com.vitorpamplona.quartz.utils.LargeCache import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -44,7 +44,7 @@ open class BaseCacheBenchmark { // This file includes duplicates val fullDBInputStream = getInstrumentation().context.assets.open("nostr_vitor_startup_data.json") - return EventMapper.mapper.readValue>( + return JsonMapper.mapper.readValue>( GZIPInputStream(fullDBInputStream), ) } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ContainsBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ContainsBenchmark.kt index bb5ee9238d..3bc743eae3 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ContainsBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ContainsBenchmark.kt @@ -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 diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Data.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Data.kt index 22f421d63a..f571a213bb 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Data.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Data.kt @@ -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 diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EnsureTest.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EnsureTest.kt index 00ca68592a..9112475910 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EnsureTest.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EnsureTest.kt @@ -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 diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventBenchmark.kt index 160c87ae9e..d7489336bc 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventBenchmark.kt @@ -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,10 +24,11 @@ import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.quartz.EventFactory -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip01Core.verify import com.vitorpamplona.quartz.nip01Core.verifyId import com.vitorpamplona.quartz.nip01Core.verifySignature +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.utils.TimeUtils import junit.framework.TestCase.assertTrue import org.junit.Rule @@ -47,28 +48,28 @@ class EventBenchmark { @Test fun parseComplete() { benchmarkRule.measureRepeated { - val tree = EventMapper.mapper.readTree(reqResponseEvent) - val event = EventMapper.fromJson(tree[2]) + val tree = JsonMapper.mapper.readTree(reqResponseEvent) + val event = JsonMapper.fromJson(tree[2]) assertTrue(event.verify()) } } @Test fun parseREQString() { - benchmarkRule.measureRepeated { EventMapper.mapper.readTree(reqResponseEvent) } + benchmarkRule.measureRepeated { JsonMapper.mapper.readTree(reqResponseEvent) } } @Test fun parseEvent() { - val msg = EventMapper.mapper.readTree(reqResponseEvent) + val msg = JsonMapper.mapper.readTree(reqResponseEvent) - benchmarkRule.measureRepeated { EventMapper.fromJson(msg[2]) } + benchmarkRule.measureRepeated { JsonMapper.fromJson(msg[2]) } } @Test fun checkId() { - val msg = EventMapper.mapper.readTree(reqResponseEvent) - val event = EventMapper.fromJson(msg[2]) + val msg = JsonMapper.mapper.readTree(reqResponseEvent) + val event = JsonMapper.fromJson(msg[2]) benchmarkRule.measureRepeated { // Should pass assertTrue(event.verifyId()) @@ -77,8 +78,8 @@ class EventBenchmark { @Test fun checkSignature() { - val msg = EventMapper.mapper.readTree(reqResponseEvent) - val event = EventMapper.fromJson(msg[2]) + val msg = JsonMapper.mapper.readTree(reqResponseEvent) + val event = JsonMapper.fromJson(msg[2]) benchmarkRule.measureRepeated { // Should pass assertTrue(event.verifySignature()) @@ -90,7 +91,7 @@ class EventBenchmark { val now = TimeUtils.now() val tags = arrayOf(arrayOf("")) benchmarkRule.measureRepeated { - EventFactory.create("id", "pubkey", now, 1, tags, "content", "sig") + EventFactory.create("id", "pubkey", now, 1, tags, "content", "sig") } } @@ -99,7 +100,7 @@ class EventBenchmark { val now = TimeUtils.now() val tags = arrayOf(arrayOf("")) benchmarkRule.measureRepeated { - EventFactory.create("id", "pubkey", now, 30818, tags, "content", "sig") + EventFactory.create("id", "pubkey", now, 30818, tags, "content", "sig") } } } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventCmdHasherBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventCmdHasherBenchmark.kt index f50b6db627..b343ff0c1d 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventCmdHasherBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventCmdHasherBenchmark.kt @@ -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,7 +25,7 @@ import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.generateId -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip01Core.verifyId import junit.framework.TestCase.assertNotNull import junit.framework.TestCase.assertTrue @@ -45,7 +45,7 @@ class EventCmdHasherBenchmark { @Test fun checkIDHashKind1WihtoutTags() { - val event = EventMapper.fromJson(EventMapper.mapper.readTree(reqResponseEvent)) + val event = JsonMapper.fromJson(JsonMapper.mapper.readTree(reqResponseEvent)) benchmarkRule.measureRepeated { // Should pass diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventCmdSerializerBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventCmdSerializerBenchmark.kt index db54fc9c33..0548430bb8 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventCmdSerializerBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EventCmdSerializerBenchmark.kt @@ -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 diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapBenchmark.kt index 33493df011..cad8498c35 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapBenchmark.kt @@ -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 @@ -32,14 +32,11 @@ import com.vitorpamplona.quartz.nip17Dm.NIP17Factory import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent -import junit.framework.TestCase +import kotlinx.coroutines.runBlocking import org.junit.Assert -import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit /** * Benchmark, which will execute on an Android device. @@ -54,102 +51,78 @@ class GiftWrapBenchmark { fun basePerformanceTest( message: String, expectedLength: Int, - ) { + ) = runBlocking { val sender = NostrSignerInternal(KeyPair()) val receiver = NostrSignerInternal(KeyPair()) - var events: NIP17Factory.Result? = null - val countDownLatch = CountDownLatch(1) - - NIP17Factory().createMessageNIP17( - ChatMessageEvent.build( - message, - listOf(PTag(receiver.pubKey)), - ), - sender, - ) { - events = it - countDownLatch.countDown() - } - - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - - val countDownLatch2 = CountDownLatch(1) + val result = + NIP17Factory().createMessageNIP17( + ChatMessageEvent.build( + message, + listOf(PTag(receiver.pubKey)), + ), + sender, + ) Assert.assertEquals( expectedLength, - events!! - .wraps - .sumOf { it.toJson().length }, + result.wraps.sumOf { it.toJson().length }, ) // Simulate Receiver - events!!.wraps.forEach { + result.wraps.forEach { it.checkSignature() val keyToUse = if (it.recipientPubKey() == sender.pubKey) sender else receiver - it.cachedGift(keyToUse) { event -> - event.checkSignature() + val event = it.unwrapThrowing(keyToUse) + event.checkSignature() - if (event is SealedRumorEvent) { - event.cachedRumor(keyToUse) { innerData -> - Assert.assertEquals(message, innerData.content) - countDownLatch2.countDown() - } - } else { - Assert.fail("Wrong Event") - } + if (event is SealedRumorEvent) { + val innerData = event.unsealThrowing(keyToUse) + Assert.assertEquals(message, innerData.content) + } else { + Assert.fail("Wrong Event") } } - - assertTrue(countDownLatch2.await(1, TimeUnit.SECONDS)) } fun receivePerformanceTest(message: String) { val sender = NostrSignerInternal(KeyPair()) val receiver = NostrSignerInternal(KeyPair()) - var giftWrap: GiftWrapEvent? = null - val countDownLatch = CountDownLatch(1) + val result = + runBlocking { + NIP17Factory().createMessageNIP17( + ChatMessageEvent.build( + message, + listOf(PTag(receiver.pubKey)), + ), + sender, + ) + } - NIP17Factory().createMessageNIP17( - ChatMessageEvent.build( - message, - listOf(PTag(receiver.pubKey)), - ), - sender, - ) { - giftWrap = it.wraps.first() - countDownLatch.countDown() - } + val giftWrap = result.wraps.first() - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - - val keyToUse = if (giftWrap!!.recipientPubKey() == sender.pubKey) sender else receiver - val giftWrapJson = giftWrap!!.toJson() + val keyToUse = if (giftWrap.recipientPubKey() == sender.pubKey) sender else receiver + val giftWrapJson = giftWrap.toJson() // Simulate Receiver benchmarkRule.measureRepeated { - val counter = CountDownLatch(1) + runBlocking { + val wrap = Event.fromJson(giftWrapJson) as GiftWrapEvent + wrap.checkSignature() - val wrap = Event.fromJson(giftWrapJson) as GiftWrapEvent - wrap.checkSignature() - - wrap.cachedGift(keyToUse) { seal -> + val seal = wrap.unwrapThrowing(keyToUse) seal.checkSignature() if (seal is SealedRumorEvent) { - seal.cachedRumor(keyToUse) { innerData -> - Assert.assertEquals(message, innerData.content) - counter.countDown() - } + val innerData = seal.unsealThrowing(keyToUse) + Assert.assertEquals(message, innerData.content) } else { Assert.fail("Wrong Event") } } - - TestCase.assertTrue(counter.await(1, TimeUnit.SECONDS)) } } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapReceivingBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapReceivingBenchmark.kt index 3bf8960596..3472b7794e 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapReceivingBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapReceivingBenchmark.kt @@ -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 @@ -40,12 +40,10 @@ import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import junit.framework.TestCase.assertNotNull -import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.runBlocking import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit /** * Benchmark, which will execute on an Android device. @@ -60,78 +58,56 @@ class GiftWrapReceivingBenchmark { fun createWrap( sender: NostrSigner, receiver: NostrSigner, - ): GiftWrapEvent { - val countDownLatch = CountDownLatch(1) - var wrap: GiftWrapEvent? = null - - sender.sign( - ChatMessageEvent.build( - msg = "Hi there! This is a test message", - to = - listOf( - PTag(receiver.pubKey), + ): GiftWrapEvent = + runBlocking { + GiftWrapEvent.create( + event = + SealedRumorEvent.create( + event = + sender.sign( + ChatMessageEvent.build( + msg = "Hi there! This is a test message", + to = listOf(PTag(receiver.pubKey)), + ) { + changeSubject("Party Tonight") + zapraiser(10000) + contentWarning("nsfw") + }, + ), + encryptTo = receiver.pubKey, + signer = sender, ), - ) { - changeSubject("Party Tonight") - zapraiser(10000) - contentWarning("nsfw") - }, - ) { - SealedRumorEvent.create( - event = it, - encryptTo = receiver.pubKey, - signer = sender, - ) { - GiftWrapEvent.create( - event = it, - recipientPubKey = receiver.pubKey, - ) { - wrap = it - countDownLatch.countDown() - } - } + recipientPubKey = receiver.pubKey, + ) } - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - - return wrap!! - } - fun createSeal( sender: NostrSigner, receiver: NostrSigner, - ): SealedRumorEvent { - val countDownLatch = CountDownLatch(1) - var seal: SealedRumorEvent? = null + ): SealedRumorEvent = + runBlocking { + val msg = + sender.sign( + ChatMessageEvent.build( + msg = "Hi there! This is a test message", + to = + listOf( + PTag(receiver.pubKey), + ), + ) { + changeSubject("Party Tonight") + zapraiser(10000) + contentWarning("nsfw") + }, + ) - sender.sign( - ChatMessageEvent.build( - msg = "Hi there! This is a test message", - to = - listOf( - PTag(receiver.pubKey), - ), - ) { - changeSubject("Party Tonight") - zapraiser(10000) - contentWarning("nsfw") - }, - ) { SealedRumorEvent.create( - event = it, + event = msg, encryptTo = receiver.pubKey, signer = sender, - ) { - seal = it - countDownLatch.countDown() - } + ) } - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - - return seal!! - } - @Test fun parseWrapFromString() { val sender = NostrSignerInternal(KeyPair()) diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapSigningBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapSigningBenchmark.kt index 5c1183c4e1..3c310d28dd 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapSigningBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapSigningBenchmark.kt @@ -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 @@ -32,12 +32,10 @@ import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent -import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.runBlocking import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit /** * Benchmark, which will execute on an Android device. @@ -55,25 +53,21 @@ class GiftWrapSigningBenchmark { val receiver = NostrSignerInternal(KeyPair()) benchmarkRule.measureRepeated { - val countDownLatch = CountDownLatch(1) - - sender.sign( - ChatMessageEvent.build( - msg = "Hi there! This is a test message", - to = - listOf( - PTag(receiver.pubKey), - ), - ) { - changeSubject("Party Tonight") - zapraiser(10000) - contentWarning("nsfw") - }, - ) { - countDownLatch.countDown() + runBlocking { + sender.sign( + ChatMessageEvent.build( + msg = "Hi there! This is a test message", + to = + listOf( + PTag(receiver.pubKey), + ), + ) { + changeSubject("Party Tonight") + zapraiser(10000) + contentWarning("nsfw") + }, + ) } - - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) } } @@ -82,40 +76,31 @@ class GiftWrapSigningBenchmark { val sender = NostrSignerInternal(KeyPair()) val receiver = NostrSignerInternal(KeyPair()) - val countDownLatch = CountDownLatch(1) - - var msg: ChatMessageEvent? = null - - sender.sign( - ChatMessageEvent.build( - msg = "Hi there! This is a test message", - to = - listOf( - PTag(receiver.pubKey), - ), - ) { - changeSubject("Party Tonight") - zapraiser(10000) - contentWarning("nsfw") - }, - ) { - msg = it - countDownLatch.countDown() - } - - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - - benchmarkRule.measureRepeated { - val countDownLatch2 = CountDownLatch(1) - SealedRumorEvent.create( - event = msg!!, - encryptTo = receiver.pubKey, - signer = sender, - ) { - countDownLatch2.countDown() + val msg = + runBlocking { + sender.sign( + ChatMessageEvent.build( + msg = "Hi there! This is a test message", + to = + listOf( + PTag(receiver.pubKey), + ), + ) { + changeSubject("Party Tonight") + zapraiser(10000) + contentWarning("nsfw") + }, + ) } - assertTrue(countDownLatch2.await(1, TimeUnit.SECONDS)) + benchmarkRule.measureRepeated { + runBlocking { + SealedRumorEvent.create( + event = msg, + encryptTo = receiver.pubKey, + signer = sender, + ) + } } } @@ -124,44 +109,39 @@ class GiftWrapSigningBenchmark { val sender = NostrSignerInternal(KeyPair()) val receiver = NostrSignerInternal(KeyPair()) - val countDownLatch = CountDownLatch(1) - - var seal: SealedRumorEvent? = null - - sender.sign( - ChatMessageEvent.build( - msg = "Hi there! This is a test message", - to = - listOf( - PTag(receiver.pubKey), - ), - ) { - changeSubject("Party Tonight") - zapraiser(10000) - contentWarning("nsfw") - }, - ) { - SealedRumorEvent.create( - event = it, - encryptTo = receiver.pubKey, - signer = sender, - ) { - seal = it - countDownLatch.countDown() + val msg = + runBlocking { + sender.sign( + ChatMessageEvent.build( + msg = "Hi there! This is a test message", + to = + listOf( + PTag(receiver.pubKey), + ), + ) { + changeSubject("Party Tonight") + zapraiser(10000) + contentWarning("nsfw") + }, + ) } - } - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + val seal = + runBlocking { + SealedRumorEvent.create( + event = msg, + encryptTo = receiver.pubKey, + signer = sender, + ) + } benchmarkRule.measureRepeated { - val countDownLatch2 = CountDownLatch(1) - GiftWrapEvent.create( - event = seal!!, - recipientPubKey = receiver.pubKey, - ) { - countDownLatch2.countDown() + runBlocking { + GiftWrapEvent.create( + event = seal!!, + recipientPubKey = receiver.pubKey, + ) } - assertTrue(countDownLatch2.await(1, TimeUnit.SECONDS)) } } @@ -170,40 +150,40 @@ class GiftWrapSigningBenchmark { val sender = NostrSignerInternal(KeyPair()) val receiver = NostrSignerInternal(KeyPair()) - val countDownLatch = CountDownLatch(1) - - var wrap: GiftWrapEvent? = null - - sender.sign( - ChatMessageEvent.build( - msg = "Hi there! This is a test message", - to = - listOf( - PTag(receiver.pubKey), - ), - ) { - changeSubject("Party Tonight") - zapraiser(10000) - contentWarning("nsfw") - }, - ) { - SealedRumorEvent.create( - event = it, - encryptTo = receiver.pubKey, - signer = sender, - ) { - GiftWrapEvent.create( - event = it, - recipientPubKey = receiver.pubKey, - ) { - wrap = it - countDownLatch.countDown() - } + val msg = + runBlocking { + sender.sign( + ChatMessageEvent.build( + msg = "Hi there! This is a test message", + to = + listOf( + PTag(receiver.pubKey), + ), + ) { + changeSubject("Party Tonight") + zapraiser(10000) + contentWarning("nsfw") + }, + ) } - } - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + val seal = + runBlocking { + SealedRumorEvent.create( + event = msg, + encryptTo = receiver.pubKey, + signer = sender, + ) + } - benchmarkRule.measureRepeated { wrap!!.toJson() } + val wrap = + runBlocking { + GiftWrapEvent.create( + event = seal, + recipientPubKey = receiver.pubKey, + ) + } + + benchmarkRule.measureRepeated { wrap.toJson() } } } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HexBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HexBenchmark.kt index beb5573725..bb1272d4e0 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HexBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HexBenchmark.kt @@ -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 diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HintIndexerBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HintIndexerBenchmark.kt index 3680267451..80c16fdfe9 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HintIndexerBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HintIndexerBenchmark.kt @@ -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,6 +27,7 @@ import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.utils.RandomInstance import org.junit.Rule import org.junit.Test @@ -52,6 +53,7 @@ class HintIndexerBenchmark { .readBytes() .toString(Charset.forName("utf-8")) .split("\n") + .mapNotNull(RelayUrlNormalizer::normalizeOrNull) } @Test @@ -74,7 +76,7 @@ class HintIndexerBenchmark { val key = keys.random() benchmarkRule.measureRepeated { - indexer.getKey(key) + indexer.hintsForKey(key) } } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeCacheBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeCacheBenchmark.kt index d26cf3613d..325c72329c 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeCacheBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeCacheBenchmark.kt @@ -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,54 +23,14 @@ package com.vitorpamplona.quartz.benchmark import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation -import com.fasterxml.jackson.module.kotlin.readValue -import com.vitorpamplona.amethyst.commons.data.LargeCache -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper -import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import java.util.Arrays -import java.util.function.Consumer -import java.util.zip.GZIPInputStream - -open class BaseLargeCacheBenchmark { - fun getEventDB(): List { - // This file includes duplicates - val fullDBInputStream = getInstrumentation().context.assets.open("nostr_vitor_startup_data.json") - - return EventMapper.mapper.readValue>( - GZIPInputStream(fullDBInputStream), - ) - } - - fun getLargeCache(db: List): LargeCache { - val cache = LargeCache() - - db.forEach { - cache.getOrCreate(it.id) { key -> - it - } - } - - return cache - } - - fun hasId(event: Event) { - assertTrue(event.id.isNotEmpty()) - } - - val consumer = - Consumer { - hasId(it) - } -} +import kotlin.collections.distinctBy @RunWith(AndroidJUnit4::class) -class LargeCacheForEachBenchmark : BaseLargeCacheBenchmark() { +class LargeCacheBenchmark : BaseLargeCacheBenchmark() { @get:Rule val benchmarkRule = BenchmarkRule() // 191,353 ns 0 allocs Trace EMULATOR_LargeCacheForEachBenchmark.benchForEachConsumerList diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBInsertBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBInsertBenchmark.kt new file mode 100644 index 0000000000..9dd3897437 --- /dev/null +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBInsertBenchmark.kt @@ -0,0 +1,115 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.benchmark + +import android.content.Context +import android.database.sqlite.SQLiteException +import android.util.Log +import android.util.Log.e +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip40Expiration.isExpired +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.collections.flatten + +@RunWith(AndroidJUnit4::class) +class LargeDBInsertBenchmark : BaseLargeCacheBenchmark() { + @get:Rule val benchmarkRule = BenchmarkRule() + + companion object { + val allEvents = getEventDB().distinctBy { it.id }.filter { !it.isExpired() }.sortedBy { it.createdAt } + val firstThousandEvents = allEvents.take(1000) + } + + @Test + fun benchInserting1000Events() { + val context = ApplicationProvider.getApplicationContext() + benchmarkRule.measureRepeated { + val db = + this.runWithTimingDisabled { + val db = EventStore(context, "test1.db") + db.store.clearDB() + db + } + firstThousandEvents.forEach { event -> + try { + db.insert(event) + } catch (e: SQLiteException) { + Log.w("LargeDBInsertBenchmark", "Error inserting event: ${e.message} for event: $event") + } + } + this.runWithTimingDisabled { + db.store.clearDB() + db.close() + } + } + } + + @Test + fun bench40DeletionRequestsEvents() { + val context = ApplicationProvider.getApplicationContext() + val deletions = allEvents.filterIsInstance() + val deletionIds = deletions.map { it.deleteEventIds() }.flatten().toSet() + val deletionAddresses = deletions.map { it.deleteAddressIds() }.flatten().toSet() + + val toBeDeletedEvents = + allEvents.filter { + (it.id in deletionIds || (it is AddressableEvent && it.addressTag() in deletionAddresses)) + } + + benchmarkRule.measureRepeated { + val db = + this.runWithTimingDisabled { + val db = EventStore(context, "test1.db") + db.store.clearDB() + toBeDeletedEvents.forEach { event -> + try { + db.insert(event) + } catch (e: SQLiteException) { + Log.w("LargeDBInsertBenchmark", "Error inserting event: ${e.message} for event: $event") + } + } + + db + } + + deletions.forEach { event -> + try { + db.insert(event) + } catch (e: SQLiteException) { + Log.w("LargeDBInsertBenchmark", "Error inserting event: ${e.message} for event: $event") + } + } + + runWithTimingDisabled { + db.store.clearDB() + db.close() + } + } + } +} diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBQueryingBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBQueryingBenchmark.kt new file mode 100644 index 0000000000..805cb08b5d --- /dev/null +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBQueryingBenchmark.kt @@ -0,0 +1,139 @@ +/** + * 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.quartz.benchmark + +import android.content.Context +import android.database.sqlite.SQLiteException +import android.util.Log +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LargeDBQueryingBenchmark : BaseLargeCacheBenchmark() { + @get:Rule val benchmarkRule = BenchmarkRule() + + companion object { + val allEvents = getEventDB().distinctBy { it.id }.sortedBy { it.createdAt } + } + + lateinit var db: EventStore + + @Before + fun setup() { + println("Running Setup") + val context = ApplicationProvider.getApplicationContext() + + db = EventStore(context, "allEvents.db") + db.store.clearDB() + allEvents.forEach { event -> + try { + db.insert(event) + } catch (e: SQLiteException) { + Log.w("LargeDBQueryingBenchmark", "Error inserting event: ${e.message} for event: $event") + } + } + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun benchQuerying1000Events() { + benchmarkRule.measureRepeated { + db.query(Filter(limit = 1000)) + } + } + + @Test + fun benchQuerying1614GiftWrapsEvents() { + benchmarkRule.measureRepeated { + db.query( + Filter( + kinds = listOf(GiftWrapEvent.KIND), + tags = mapOf("p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")), + ), + ) + } + } + + @Test + fun benchQueryingEventsByAuthor() { + benchmarkRule.measureRepeated { + db.query( + Filter( + authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + ), + ) + } + } + + @Test + fun benchQueryingMetadataByAuthor() { + benchmarkRule.measureRepeated { + db.query( + Filter( + kinds = listOf(MetadataEvent.KIND), + authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + ), + ) + } + } + + @Test + fun benchQueryingRelayListByAuthor() { + benchmarkRule.measureRepeated { + db.query( + Filter( + kinds = listOf(AdvertisedRelayListEvent.KIND), + authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + ), + ) + } + } + + @Test + fun benchQueryingEmptyReturnByTags() { + benchmarkRule.measureRepeated { + db.query(Filter(tags = mapOf("p" to listOf("176d972f60dcdc3212ed8c92ef85065c176d972f60dcdc3212ed8c92ef85065c")))) + } + } + + @Test + fun benchQueryingEmptyReturnByIds() { + benchmarkRule.measureRepeated { + db.query(Filter(ids = listOf("176d972f60dcdc3212ed8c92ef85065c176d972f60dcdc3212ed8c92ef85065c"))) + } + } +} diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/MurMurBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/MurMurBenchmark.kt index 7f79cd35d1..f20f26ee11 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/MurMurBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/MurMurBenchmark.kt @@ -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 diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/PoWBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/PoWBenchmark.kt index fd3b2bc954..90f030ab08 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/PoWBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/PoWBenchmark.kt @@ -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 diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/RandomBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/RandomBenchmark.kt index f230500e45..4b56b0bfe0 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/RandomBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/RandomBenchmark.kt @@ -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 diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Sha256Benchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Sha256Benchmark.kt index 5c176a8aa2..d49a74efd0 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Sha256Benchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Sha256Benchmark.kt @@ -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 diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/SharedKeyBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/SharedKeyBenchmark.kt index 2a2a9d3225..9a7d394281 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/SharedKeyBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/SharedKeyBenchmark.kt @@ -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 diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/SignVerifyBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/SignVerifyBenchmark.kt index 288995e945..5981d98ff0 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/SignVerifyBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/SignVerifyBenchmark.kt @@ -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 diff --git a/build.gradle b/build.gradle index 3646b9e716..7f204f9e88 100644 --- a/build.gradle +++ b/build.gradle @@ -18,7 +18,7 @@ allprojects { } spotlessPredeclare { kotlin { - ktlint("1.3.1") + ktlint() } } } else { @@ -26,7 +26,7 @@ allprojects { kotlin { target 'src/**/*.kt' - ktlint("1.3.1") + ktlint() licenseHeaderFile rootProject.file('spotless/copyright.kt'), "package|import|class|object|sealed|open|interface|abstract " } diff --git a/commons/build.gradle b/commons/build.gradle index 37614f930e..82c054688c 100644 --- a/commons/build.gradle +++ b/commons/build.gradle @@ -7,12 +7,12 @@ plugins { } android { - namespace 'com.vitorpamplona.amethyst.commons' - compileSdk libs.versions.android.compileSdk.get().toInteger() + namespace = 'com.vitorpamplona.amethyst.commons' + compileSdk = libs.versions.android.compileSdk.get().toInteger() defaultConfig { - minSdk libs.versions.android.minSdk.get().toInteger() - targetSdk libs.versions.android.targetSdk.get().toInteger() + minSdk = libs.versions.android.minSdk.get().toInteger() + targetSdk = libs.versions.android.targetSdk.get().toInteger() testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" @@ -25,7 +25,7 @@ android { } create("benchmark") { initWith(getByName("release")) - signingConfig signingConfigs.debug + signingConfig = signingConfigs.debug } } compileOptions { @@ -34,7 +34,7 @@ android { } buildFeatures { - compose true + compose = true } } diff --git a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensionTest.kt b/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensionTest.kt index 0ec304bf73..57b5218c8a 100644 --- a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensionTest.kt +++ b/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensionTest.kt @@ -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 diff --git a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/preview/BlurhashTest.kt b/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/preview/BlurhashTest.kt index c533ffee94..678a654fbc 100644 --- a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/preview/BlurhashTest.kt +++ b/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/preview/BlurhashTest.kt @@ -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 BlurhashTest { @Test fun testLorikeet() { val blurhash = load("/lorikeet.jpg").toBlurhash() - assertEquals("rFDcT@_LNs#p%Mt*nNM}E2VrIVX6VuV@WUo{xtjv9]RRw[OXS}rrWFX9w{OZxaxWNHX4n\$M}NGaK%0RkM}w{xto|jFs,Sh-Tj]bcwJnjXSxZs.NI", blurhash) + assertEquals("rFDcT@_LNs#:-pyBnhRRE2Z~MyX5VuV@WUo{xta\$9]RQw[OXS}rrWFXSw|OsxaxWNHSwn~M}NGaK%0RkM}w{xto|jGs+Sh-Tj]W?wJnjXSxGs.NI", blurhash) } private fun load(filename: String): Bitmap = diff --git a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/preview/MetaTagsParserTest.kt b/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/preview/MetaTagsParserTest.kt index 711da77fe8..00367cf288 100644 --- a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/preview/MetaTagsParserTest.kt +++ b/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/preview/MetaTagsParserTest.kt @@ -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 diff --git a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculatorTest.kt b/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculatorTest.kt index 0d9643be18..e8af359ba0 100644 --- a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculatorTest.kt +++ b/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculatorTest.kt @@ -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 diff --git a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt b/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt index 6e3e7d0f01..e756503f42 100644 --- a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt +++ b/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt index 3317cdd914..cc26f55498 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt @@ -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 @@ -29,7 +29,7 @@ import java.util.regex.Pattern class Base64Image { companion object { - val pattern = Pattern.compile("data:image/(${RichTextParser.Companion.imageExtensions.joinToString(separator = "|") { it } });base64,([a-zA-Z0-9+/]+={0,2})") + val pattern = Pattern.compile("data:image/(${RichTextParser.imageExtensions.joinToString(separator = "|") { it } });base64,([a-zA-Z0-9+/]+={0,2})") fun isBase64(content: String): Boolean { val matcher = pattern.matcher(content) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt index c62598555b..2e6eebe243 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt index 0457e70917..7a3bd652c9 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt index 0548d763fa..baf540fd39 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoderOld.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoderOld.kt index 81fda02c17..83ed014bed 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoderOld.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoderOld.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt index c48b5e0a53..6bdbb4acca 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt index 5f18cbaf5b..7248cf5a13 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/SRGB.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/SRGB.kt index e2cb5df154..d00b6c081d 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/SRGB.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/SRGB.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/AsyncCachedState.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/AsyncCachedState.kt index 276d7d1c5a..733dfdac20 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/AsyncCachedState.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/AsyncCachedState.kt @@ -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 @@ -32,8 +32,9 @@ fun produceCachedStateAsync( ): State = @Suppress("ProduceStateDoesNotAssignValue") produceState(initialValue = cache.cached(key), key1 = key) { - cache.update(key) { - value = it + val newValue = cache.update(key) + if (newValue != value) { + value = newValue } } @@ -45,18 +46,16 @@ fun produceCachedStateAsync( ): State = @Suppress("ProduceStateDoesNotAssignValue") produceState(initialValue = cache.cached(updateValue), key1 = key) { - cache.update(updateValue) { - value = it + val newValue = cache.update(updateValue) + if (newValue != value) { + value = newValue } } interface AsyncCachedState { fun cached(k: K): V? - suspend fun update( - k: K, - onReady: (V?) -> Unit, - ) + suspend fun update(k: K): V? } abstract class GenericBaseCacheAsync( @@ -66,23 +65,17 @@ abstract class GenericBaseCacheAsync( override fun cached(k: K): V? = cache[k] - override suspend fun update( - k: K, - onReady: (V?) -> Unit, - ) { - cache[k]?.let { onReady(it) } + override suspend fun update(k: K): V? { + cache[k]?.let { return it } - compute(k) { - if (it != null) { - cache.put(k, it) - } + val newValue = compute(k) - onReady(it) + if (newValue != null) { + cache.put(k, newValue) } + + return newValue } - abstract suspend fun compute( - key: K, - onReady: (V?) -> Unit, - ) + abstract suspend fun compute(key: K): V? } diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/CachedState.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/CachedState.kt index 701faf2ede..ba955f4d66 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/CachedState.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/CachedState.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensions.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensions.kt index e9cca78b36..26ad670af9 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensions.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensions.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/data/LargeCache.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/data/LargeCache.kt deleted file mode 100644 index ed2cbc9d55..0000000000 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/data/LargeCache.kt +++ /dev/null @@ -1,425 +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.commons.data - -import java.util.concurrent.ConcurrentSkipListMap -import java.util.function.BiConsumer - -class LargeCache { - private val cache = ConcurrentSkipListMap() - - fun get(key: K) = cache.get(key) - - fun remove(key: K) = cache.remove(key) - - fun size() = cache.size - - fun isEmpty() = cache.isEmpty() - - fun containsKey(key: K) = cache.containsKey(key) - - fun put( - key: K, - value: V, - ) { - cache.put(key, value) - } - - fun getOrCreate( - key: K, - builder: (key: K) -> V, - ): V { - val value = cache.get(key) - - return if (value != null) { - value - } else { - val newObject = builder(key) - cache.putIfAbsent(key, newObject) ?: newObject - } - } - - fun forEach(consumer: BiConsumer) { - innerForEach(consumer) - } - - fun filter(consumer: BiFilter): List { - val runner = BiFilterCollector(consumer) - innerForEach(runner) - return runner.results - } - - fun filterIntoSet(consumer: BiFilter): Set { - val runner = BiFilterUniqueCollector(consumer) - innerForEach(runner) - return runner.results - } - - fun map(consumer: BiNotNullMapper): List { - val runner = BiNotNullMapCollector(consumer) - innerForEach(runner) - return runner.results - } - - fun mapNotNull(consumer: BiMapper): List { - val runner = BiMapCollector(consumer) - innerForEach(runner) - return runner.results - } - - fun mapNotNullIntoSet(consumer: BiMapper): Set { - val runner = BiMapUniqueCollector(consumer) - innerForEach(runner) - return runner.results - } - - fun mapFlatten(consumer: BiMapper?>): List { - val runner = BiMapFlattenCollector(consumer) - innerForEach(runner) - return runner.results - } - - fun mapFlattenIntoSet(consumer: BiMapper?>): Set { - val runner = BiMapFlattenUniqueCollector(consumer) - innerForEach(runner) - return runner.results - } - - fun maxOrNullOf( - filter: BiFilter, - comparator: Comparator, - ): V? { - val runner = BiMaxOfCollector(filter, comparator) - innerForEach(runner) - return runner.maxV - } - - fun sumOf(consumer: BiSumOf): Int { - val runner = BiSumOfCollector(consumer) - innerForEach(runner) - return runner.sum - } - - fun sumOfLong(consumer: BiSumOfLong): Long { - val runner = BiSumOfLongCollector(consumer) - innerForEach(runner) - return runner.sum - } - - fun groupBy(consumer: BiNotNullMapper): Map> { - val runner = BiGroupByCollector(consumer) - innerForEach(runner) - return runner.results - } - - fun countByGroup(consumer: BiNotNullMapper): Map { - val runner = BiCountByGroupCollector(consumer) - innerForEach(runner) - return runner.results - } - - fun sumByGroup( - groupMap: BiNotNullMapper, - sumOf: BiNotNullMapper, - ): Map { - val runner = BiSumByGroupCollector(groupMap, sumOf) - innerForEach(runner) - return runner.results - } - - fun count(consumer: BiFilter): Int { - val runner = BiCountIfCollector(consumer) - innerForEach(runner) - return runner.count - } - - private fun innerForEach(runner: BiConsumer) { - // val (value, elapsed) = - // measureTimedValue { - cache.forEach(runner) - // } - // println("LargeCache full loop $elapsed \t for $runner") - } -} - -fun interface BiFilter { - fun filter( - k: K, - v: V, - ): Boolean -} - -class BiFilterCollector( - val filter: BiFilter, -) : BiConsumer { - var results: ArrayList = ArrayList() - - override fun accept( - k: K, - v: V, - ) { - if (filter.filter(k, v)) { - results.add(v) - } - } -} - -class BiFilterUniqueCollector( - val filter: BiFilter, -) : BiConsumer { - var results: HashSet = HashSet() - - override fun accept( - k: K, - v: V, - ) { - if (filter.filter(k, v)) { - results.add(v) - } - } -} - -fun interface BiMapper { - fun map( - k: K, - v: V, - ): R? -} - -class BiMapCollector( - val mapper: BiMapper, -) : BiConsumer { - var results: ArrayList = ArrayList() - - override fun accept( - k: K, - v: V, - ) { - val result = mapper.map(k, v) - if (result != null) { - results.add(result) - } - } -} - -class BiMapUniqueCollector( - val mapper: BiMapper, -) : BiConsumer { - var results: HashSet = HashSet() - - override fun accept( - k: K, - v: V, - ) { - val result = mapper.map(k, v) - if (result != null) { - results.add(result) - } - } -} - -class BiMapFlattenCollector( - val mapper: BiMapper?>, -) : BiConsumer { - var results: ArrayList = ArrayList() - - override fun accept( - k: K, - v: V, - ) { - val result = mapper.map(k, v) - if (result != null) { - results.addAll(result) - } - } -} - -class BiMapFlattenUniqueCollector( - val mapper: BiMapper?>, -) : BiConsumer { - var results: HashSet = HashSet() - - override fun accept( - k: K, - v: V, - ) { - val result = mapper.map(k, v) - if (result != null) { - results.addAll(result) - } - } -} - -fun interface BiNotNullMapper { - fun map( - k: K, - v: V, - ): R -} - -class BiNotNullMapCollector( - val mapper: BiNotNullMapper, -) : BiConsumer { - var results: ArrayList = ArrayList() - - override fun accept( - k: K, - v: V, - ) { - results.add(mapper.map(k, v)) - } -} - -fun interface BiSumOf { - fun map( - k: K, - v: V, - ): Int -} - -class BiMaxOfCollector( - val filter: BiFilter, - val comparator: Comparator, -) : BiConsumer { - var maxK: K? = null - var maxV: V? = null - - override fun accept( - k: K, - v: V, - ) { - if (filter.filter(k, v)) { - if (maxK == null || comparator.compare(v, maxV) > 0) { - maxK = k - maxV = v - } - } - } -} - -class BiSumOfCollector( - val mapper: BiSumOf, -) : BiConsumer { - var sum = 0 - - override fun accept( - k: K, - v: V, - ) { - sum += mapper.map(k, v) - } -} - -fun interface BiSumOfLong { - fun map( - k: K, - v: V, - ): Long -} - -class BiSumOfLongCollector( - val mapper: BiSumOfLong, -) : BiConsumer { - var sum = 0L - - override fun accept( - k: K, - v: V, - ) { - sum += mapper.map(k, v) - } -} - -class BiGroupByCollector( - val mapper: BiNotNullMapper, -) : BiConsumer { - var results = HashMap>() - - override fun accept( - k: K, - v: V, - ) { - val group = mapper.map(k, v) - - val list = results[group] - if (list == null) { - val answer = ArrayList() - answer.add(v) - results[group] = answer - } else { - list.add(v) - } - } -} - -class BiCountByGroupCollector( - val mapper: BiNotNullMapper, -) : BiConsumer { - var results = HashMap() - - override fun accept( - k: K, - v: V, - ) { - val group = mapper.map(k, v) - - val count = results[group] - if (count == null) { - results[group] = 1 - } else { - results[group] = count + 1 - } - } -} - -class BiSumByGroupCollector( - val mapper: BiNotNullMapper, - val sumOf: BiNotNullMapper, -) : BiConsumer { - var results = HashMap() - - override fun accept( - k: K, - v: V, - ) { - val group = mapper.map(k, v) - - val sum = results[group] - if (sum == null) { - results[group] = sumOf.map(k, v) - } else { - results[group] = sum + sumOf.map(k, v) - } - } -} - -class BiCountIfCollector( - val filter: BiFilter, -) : BiConsumer { - var count = 0 - - override fun accept( - k: K, - v: V, - ) { - if (filter.filter(k, v)) count++ - } -} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt index d3ddf3e971..89da1ce7c8 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt index 257dc78028..0a7ee0dd01 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt index 48c6f69b89..5a4fc5ba20 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt index 1f4ad7e404..de72f6eb4a 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt index 1e5c1b49d6..f59def11ec 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/CustomHashTagIcons.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/CustomHashTagIcons.kt index 15917ff455..afa58a49f3 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/CustomHashTagIcons.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/CustomHashTagIcons.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt index 2d7947d4da..7cb20efa84 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt index 2128b19351..6b907ede52 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt index 38f34c5b9e..fec5833a7a 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt index f9bf649e42..dc66289747 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt index 879430bc16..281a1688d8 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt index 1a95b82cbf..5b27fd5bd6 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt index c28bf3334b..b883179111 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt index e39b23b2c9..7ce7b8ae69 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt index 515446ef9b..6a4d95899c 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt index 14c3d65b41..9add8ed4bd 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt index 7d7c161a64..4a40e3d165 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt index 834c4b459c..b98538cf00 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Following.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Following.kt index b036227999..2eb7b112d7 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Following.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Following.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Like.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Like.kt index 3a2e3e693d..afeccd3aed 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Like.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Like.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Liked.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Liked.kt index 2e58b4cf42..b509ebc10b 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Liked.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Liked.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Reply.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Reply.kt index 0c5e1adfc5..4db2c8b121 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Reply.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Reply.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Repost.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Repost.kt index d99d4a823d..a599d032f9 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Repost.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Repost.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Reposted.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Reposted.kt index 28563c104e..6e11930eb8 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Reposted.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Reposted.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Search.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Search.kt index f3be659d2d..e996be38a9 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Search.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Search.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Share.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Share.kt index a4c58913a8..5107eca3f9 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Share.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Share.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Zap.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Zap.kt index bd45756ed1..acf40c45c7 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Zap.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Zap.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt index 94e3dd2322..84ef32ff73 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt index d859c78ca7..f915f94234 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt @@ -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 @@ -82,6 +82,7 @@ object MetaTagsParser { fun nextTag(): RawTag? { skipWhile { it != '<' } + if (this.exhausted()) return null consume() // read tag name @@ -278,38 +279,34 @@ object MetaTagsParser { State.VALUE -> { var attr: Pair? = null - when { - valueQuote != null -> { - if (c == valueQuote) { + if (valueQuote != null) { + if (c == valueQuote) { + attr = + Pair( + input.slice(nameBegin.. { attr = Pair( input.slice(nameBegin.. { - when { - c.isWhitespace() -> { - attr = - Pair( - input.slice(nameBegin.. { + attr = + Pair( + input.slice(nameBegin.. { - attr = - Pair( - input.slice(nameBegin.. { - return null - } + NON_UNQUOTED_ATTR_VALUE_CHARS.contains(c) -> { + return null } } } diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculator.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculator.kt index 1925c7931f..11b5359f8b 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculator.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculator.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt index 94f03e8201..9e65c76bf5 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 65cbdab2a5..6d17f3189e 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -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 @@ -367,11 +367,11 @@ class RichTextParser { private fun removeQueryParamsForExtensionComparison(fullUrl: String): String = if (fullUrl.contains("?")) { - fullUrl.split("?")[0].lowercase() + fullUrl.split("?")[0] } else if (fullUrl.contains("#")) { - fullUrl.split("#")[0].lowercase() + fullUrl.split("#")[0] } else { - fullUrl.lowercase() + fullUrl } fun isImageOrVideoUrl(url: String): Boolean { diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt index e794e50ceb..4bdc43ad7f 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/CachedRobohash.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/CachedRobohash.kt index 222af81e62..8811c0fb0a 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/CachedRobohash.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/CachedRobohash.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt index 9c0402dda8..3f414bfd18 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt index f6121ce97d..3f97d864f0 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt index 6078329336..2793862ab9 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt index 119476e966..3f6f9813dd 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt index ac07db5699..bee5d04465 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt index c3a1d587ef..cf10cd4e38 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt index 885ad61455..9967abd02e 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory6Hat.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory6Hat.kt index 3169d0ccd7..ea3809b9cc 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory6Hat.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory6Hat.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory7Antenna.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory7Antenna.kt index 299d864208..9bf7fd5702 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory7Antenna.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory7Antenna.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory8Brush.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory8Brush.kt index 0c3b172be1..3b94fcf84a 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory8Brush.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory8Brush.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory9Horn.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory9Horn.kt index bdb9f79f01..a9baa4597a 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory9Horn.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory9Horn.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt index cc05d13f6f..e84bd23ed8 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt index dacef2e5fc..6d55022896 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt index b9a9264659..ead54ed93c 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt index 0d3936ed29..aa95e81089 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt index 7ac9a33c96..1ec10ab8b8 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt index dcc6870888..ff939af00e 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt index 38e38e700e..62c505821d 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt index d062628ef3..58bef8c433 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt index 49ce176612..d12739adbb 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt index 13fab5a7f1..841aef1620 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes0Squint.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes0Squint.kt index 660567de06..52486c701f 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes0Squint.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes0Squint.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes1Round.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes1Round.kt index 63a38084cb..0535fad990 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes1Round.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes1Round.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes2Single.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes2Single.kt index c5cd7d2353..fcf23af08b 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes2Single.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes2Single.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes3Scott.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes3Scott.kt index 9dfb946350..f8ffd01ca2 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes3Scott.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes3Scott.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes4Roundsingle.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes4Roundsingle.kt index 329e84acc6..5d3d553122 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes4Roundsingle.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes4Roundsingle.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes5Roundsmall.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes5Roundsmall.kt index aa0e2cb749..4c30a32400 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes5Roundsmall.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes5Roundsmall.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes6Walle.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes6Walle.kt index 79fd14ceb5..11e1bf901b 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes6Walle.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes6Walle.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes7Bar.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes7Bar.kt index d253df3ec2..7bd1093ad3 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes7Bar.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes7Bar.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes8Smallbar.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes8Smallbar.kt index 9191a99cb2..3064d1665b 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes8Smallbar.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes8Smallbar.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes9Shield.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes9Shield.kt index 678caf57f4..20ab0a8754 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes9Shield.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes9Shield.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face0C3po.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face0C3po.kt index 900501c14c..4d18e34532 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face0C3po.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face0C3po.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face1Rock.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face1Rock.kt index 5e02f063e5..1fbf5f73d2 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face1Rock.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face1Rock.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face2Long.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face2Long.kt index c47cde6939..6c5a51cab6 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face2Long.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face2Long.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face3Oval.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face3Oval.kt index a4adeee26c..bc3979d891 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face3Oval.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face3Oval.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face4Cylinder.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face4Cylinder.kt index 6dcfae2547..8707de72c7 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face4Cylinder.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face4Cylinder.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face5Baloon.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face5Baloon.kt index 817abe3b42..bc16e370fd 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face5Baloon.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face5Baloon.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face6Triangle.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face6Triangle.kt index cc44bdcd6e..b592cd4ed4 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face6Triangle.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face6Triangle.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face7Bent.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face7Bent.kt index 2cb5b81503..b9e888caa6 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face7Bent.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face7Bent.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face8TriangleInv.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face8TriangleInv.kt index b576b73644..d0c5bf806a 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face8TriangleInv.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face8TriangleInv.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face9Square.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face9Square.kt index f1bbd2f23e..cfd5b8f7ba 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face9Square.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face9Square.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth0Horz.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth0Horz.kt index 80c0411c39..fb0de441ae 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth0Horz.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth0Horz.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth1Cylinder.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth1Cylinder.kt index 5510349db3..85925bc213 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth1Cylinder.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth1Cylinder.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth2Teeth.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth2Teeth.kt index 95226626ab..44cb1951f0 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth2Teeth.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth2Teeth.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth3Grid.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth3Grid.kt index d965436a3e..6069be7996 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth3Grid.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth3Grid.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth4Vert.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth4Vert.kt index 32744632c5..bf827cddeb 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth4Vert.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth4Vert.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth5Midopen.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth5Midopen.kt index 36a8c028f3..080db59802 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth5Midopen.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth5Midopen.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth6Cell.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth6Cell.kt index c3e5a3f78e..bece6f0914 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth6Cell.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth6Cell.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth7Happy.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth7Happy.kt index a6724134b5..30983dc1f9 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth7Happy.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth7Happy.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth8Buttons.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth8Buttons.kt index 2d9ec56300..9981f43918 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth8Buttons.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth8Buttons.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth9Closed.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth9Closed.kt index 15a9497b57..2890d73768 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth9Closed.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth9Closed.kt @@ -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 diff --git a/commons/src/test/java/com/vitorpamplona/amethyst/commons/Base83Test.kt b/commons/src/test/java/com/vitorpamplona/amethyst/commons/Base83Test.kt index 8692361f8c..a43b20b023 100644 --- a/commons/src/test/java/com/vitorpamplona/amethyst/commons/Base83Test.kt +++ b/commons/src/test/java/com/vitorpamplona/amethyst/commons/Base83Test.kt @@ -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 diff --git a/commons/src/test/java/com/vitorpamplona/amethyst/commons/SRGBTest.kt b/commons/src/test/java/com/vitorpamplona/amethyst/commons/SRGBTest.kt index 0fe89dd49b..1ba00329e9 100644 --- a/commons/src/test/java/com/vitorpamplona/amethyst/commons/SRGBTest.kt +++ b/commons/src/test/java/com/vitorpamplona/amethyst/commons/SRGBTest.kt @@ -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 diff --git a/commons/src/test/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoderTest.kt b/commons/src/test/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoderTest.kt index 5157378bd2..423e689c32 100644 --- a/commons/src/test/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoderTest.kt +++ b/commons/src/test/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoderTest.kt @@ -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,7 @@ class EmojiCoderTest { "Testing 123", "Special chars: !@#$%^&*()", "Unicode: 你好,世界", - " ", // space only + " ", ) val HELLO_WORLD = "\uD83D\uDE00\uDB40\uDD38\uDB40\uDD55\uDB40\uDD5C\uDB40\uDD5C\uDB40\uDD5F\uDB40\uDD1C\uDB40\uDD10\uDB40\uDD47\uDB40\uDD5F\uDB40\uDD62\uDB40\uDD5C\uDB40\uDD54\uDB40\uDD11" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c1fdf46e5b..7e4ba2464d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,57 +1,58 @@ [versions] -accompanistAdaptive = "0.37.2" +accompanistAdaptive = "0.37.3" activityCompose = "1.10.1" -agp = "8.9.1" -android-compileSdk = "35" +agp = "8.12.0" +android-compileSdk = "36" android-minSdk = "26" -android-targetSdk = "35" -androidKotlinGeohash = "1.0" -androidxJunit = "1.2.1" -appcompat = "1.7.0" -audiowaveform = "1.1.1" -benchmark = "1.3.4" -benchmarkJunit4 = "1.3.4" +android-targetSdk = "36" +androidKotlinGeohash = "b481c6a64e" +androidxJunit = "1.3.0" +appcompat = "1.7.1" +audiowaveform = "1.1.2" +benchmark = "1.4.0" +benchmarkJunit4 = "1.4.0" biometricKtx = "1.2.0-alpha05" -coil = "3.1.0" -composeBom = "2025.04.00" +coil = "3.3.0" +composeBom = "2025.07.00" coreKtx = "1.16.0" -espressoCore = "3.6.1" -firebaseBom = "33.12.0" -fragmentKtx = "1.8.6" -gms = "4.4.2" -jacksonModuleKotlin = "2.18.3" +datastore = "1.1.7" +espressoCore = "3.7.0" +firebaseBom = "34.0.0" +fragmentKtx = "1.8.8" +gms = "4.4.3" +jacksonModuleKotlin = "2.19.2" jna = "5.17.0" jtorctl = "0.4.5.7" junit = "4.13.2" -kotlin = "2.1.0" -kotlinxCollectionsImmutable = "0.3.8" -kotlinxSerialization = "1.8.1" -kotlinxSerializationPlugin = "2.0.0" +kotlin = "2.2.0" +kotlinxCollectionsImmutable = "0.4.0" +kotlinxSerialization = "1.9.0" +kotlinxSerializationPlugin = "2.2.0" languageId = "17.0.6" -lazysodiumAndroid = "5.1.0" -lifecycleRuntimeKtx = "2.8.7" -lightcompressor = "1.3.2" -markdown = "077a2cde64" -media3 = "1.6.0" -mockk = "1.14.0" +lazysodiumAndroid = "5.2.0" +lifecycleRuntimeKtx = "2.9.2" +lightcompressor = "1.3.3" +markdown = "e1151c8" +media3 = "1.8.0" +mockk = "1.14.5" kotlinx-coroutines-test = "1.10.2" -navigationCompose = "2.8.9" -okhttp = "5.0.0-alpha.14" -runner = "1.6.2" +navigationCompose = "2.9.3" +okhttp = "5.1.0" +runner = "1.7.0" rfc3986 = "0.1.2" -secp256k1KmpJniAndroid = "0.17.1" -securityCryptoKtx = "1.1.0-alpha07" -spotless = "6.25.0" -torAndroid = "0.4.8.12" +secp256k1KmpJniAndroid = "0.18.0" +securityCryptoKtx = "1.1.0" +spotless = "7.2.1" +torAndroid = "0.4.8.17" translate = "17.0.3" -unifiedpush = "2.3.1" +unifiedpush = "3.0.10" urlDetector = "0.1.23" -vico-charts = "2.1.2" +vico-charts = "2.1.3" zelory = "3.0.1" -zoomable = "2.5.0" +zoomable = "2.8.1" zxing = "3.5.3" zxingAndroidEmbedded = "4.3.0" -windowCoreAndroid = "1.3.0" +windowCoreAndroid = "1.4.0" androidxCamera = "1.4.2" [libraries] @@ -69,12 +70,12 @@ androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = " androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "androidxCamera" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragmentKtx" } androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } androidx-junit-ktx = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "androidxJunit" } -androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" } @@ -88,7 +89,6 @@ androidx-media3-session = { group = "androidx.media3", name = "media3-session", androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } -androidx-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" } androidx-runtime-runtime = { group = "androidx.compose.runtime", name = "runtime" } androidx-security-crypto-ktx = { group = "androidx.security", name = "security-crypto-ktx", version.ref = "securityCryptoKtx" } androidx-ui = { group = "androidx.compose.ui", name = "ui" } @@ -103,7 +103,7 @@ coil-svg = { group = "io.coil-kt.coil3", name = "coil-svg", version.ref = "coil" coil-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" } drfonfon-geohash = { group = "com.github.drfonfon", name = "android-kotlin-geohash", version.ref = "androidKotlinGeohash" } firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } -firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging-ktx" } +firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" } google-mlkit-language-id = { group = "com.google.mlkit", name = "language-id", version.ref = "languageId" } google-mlkit-translate = { group = "com.google.mlkit", name = "translate", version.ref = "translate" } jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin", version.ref = "jacksonModuleKotlin" } @@ -111,6 +111,7 @@ jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" } jtorctl = { module = "info.guardianproject:jtorctl", version.ref = "jtorctl" } junit = { group = "junit", name = "junit", version.ref = "junit" } kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kotlinx-serialization-cbor = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cbor", version.ref = "kotlinxSerialization" } lazysodium-android = { group = "com.goterl", name = "lazysodium-android", version.ref = "lazysodiumAndroid" } markdown-commonmark = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-commonmark", version.ref = "markdown" } @@ -119,6 +120,7 @@ markdown-ui-material3 = { group = "com.github.vitorpamplona.compose-richtext", n mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test"} okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" } rfc3986-normalizer = { group = "org.czeal", name = "rfc3986", version.ref = "rfc3986" } secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" } secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 17098b30bc..da1d086f4f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Wed Jan 04 09:23:50 EST 2023 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME diff --git a/quartz/build.gradle b/quartz/build.gradle index 12e9db7ed2..d6e191a402 100644 --- a/quartz/build.gradle +++ b/quartz/build.gradle @@ -6,12 +6,12 @@ plugins { } android { - namespace 'com.vitorpamplona.quartz' - compileSdk libs.versions.android.compileSdk.get().toInteger() + namespace = 'com.vitorpamplona.quartz' + compileSdk = libs.versions.android.compileSdk.get().toInteger() defaultConfig { - minSdk libs.versions.android.minSdk.get().toInteger() - targetSdk libs.versions.android.targetSdk.get().toInteger() + minSdk = libs.versions.android.minSdk.get().toInteger() + targetSdk = libs.versions.android.targetSdk.get().toInteger() testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" @@ -19,12 +19,12 @@ android { buildTypes { release { - minifyEnabled true + minifyEnabled = true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } create("benchmark") { initWith(getByName("release")) - signingConfig signingConfigs.debug + signingConfig = signingConfigs.debug } } compileOptions { @@ -47,7 +47,6 @@ kotlin { dependencies { implementation libs.androidx.core.ktx - implementation platform(libs.androidx.compose.bom) // @Immutable and @Stable @@ -58,11 +57,12 @@ dependencies { // LibSodium for ChaCha encryption (NIP-44) // Wait for @aar support in version catalogs - implementation "com.goterl:lazysodium-android:5.1.0@aar" - implementation 'net.java.dev.jna:jna:5.17.0@aar' - //implementation (libs.lazysodium.android) { artifact { type = "aar" } } - //implementation (libs.jna) { artifact { type = "aar" } } + // implementation 'com.goterl:lazysodium-android:5.2.0@aar' + // implementation 'net.java.dev.jna:jna:5.17.0@aar' + + implementation variantOf(libs.lazysodium.android) { artifactType("aar") } + implementation variantOf(libs.jna) { artifactType("aar") } // Performant Parser of JSONs into Events api libs.jackson.module.kotlin diff --git a/quartz/notebooks/Kind1Test.ipynb b/quartz/notebooks/Kind1Test.ipynb new file mode 100644 index 0000000000..707a981a83 --- /dev/null +++ b/quartz/notebooks/Kind1Test.ipynb @@ -0,0 +1,147 @@ +{ + "cells": [ + { + "metadata": {}, + "cell_type": "markdown", + "source": "## Creating a new Kind 1 post" + }, + { + "metadata": { + "jupyter": { + "is_executing": true + } + }, + "cell_type": "code", + "source": [ + "USE {\n", + " dependencies(\"fr.acinq.secp256k1:secp256k1-kmp-jni-jvm:0.17.3\")\n", + " dependencies(\"com.goterl:lazysodium-java:5.1.4\")\n", + " dependencies(\"net.java.dev.jna:jna:5.17.0\")\n", + "\n", + " import(\"com.vitorpamplona.quartz.*\")\n", + "}" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": { + "collapsed": true, + "ExecuteTime": { + "end_time": "2025-06-03T14:42:40.051027Z", + "start_time": "2025-06-03T14:42:40.023052Z" + } + }, + "source": [ + "import fr.acinq.secp256k1.Secp256k1\n", + "\n", + "import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair\n", + "import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal\n", + "import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync\n", + "import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent\n", + "\n", + "val signer = NostrSignerSync()\n", + "val kind1Template = TextNoteEvent.build(\"New kind 1 Post\")\n", + "val signedEvent = signer.sign(kind1Template)\n", + "\n", + "println(signedEvent)" + ], + "outputs": [ + { + "ename": "org.jetbrains.kotlinx.jupyter.exceptions.ReplCompilerException", + "evalue": "at Cell In[5], line 3, column 43: Unresolved reference: crypto\nat Cell In[5], line 4, column 43: Unresolved reference: signers\nat Cell In[5], line 5, column 43: Unresolved reference: signers\nat Cell In[5], line 6, column 33: Unresolved reference: nip10Notes\nat Cell In[5], line 8, column 14: Unresolved reference: NostrSignerSync\nat Cell In[5], line 9, column 21: Unresolved reference: TextNoteEvent\nat Cell In[5], line 12, column 1: Overload resolution ambiguity: \npublic inline fun println(message: Any?): Unit defined in kotlin.io\npublic inline fun println(message: Boolean): Unit defined in kotlin.io\npublic inline fun println(message: Byte): Unit defined in kotlin.io\npublic inline fun println(message: Char): Unit defined in kotlin.io\npublic inline fun println(message: CharArray): Unit defined in kotlin.io\npublic inline fun println(message: Double): Unit defined in kotlin.io\npublic inline fun println(message: Float): Unit defined in kotlin.io\npublic inline fun println(message: Int): Unit defined in kotlin.io\npublic inline fun println(message: Long): Unit defined in kotlin.io\npublic inline fun println(message: Short): Unit defined in kotlin.io", + "output_type": "error", + "traceback": [ + "org.jetbrains.kotlinx.jupyter.exceptions.ReplCompilerException: at Cell In[5], line 3, column 43: Unresolved reference: crypto", + "at Cell In[5], line 4, column 43: Unresolved reference: signers", + "at Cell In[5], line 5, column 43: Unresolved reference: signers", + "at Cell In[5], line 6, column 33: Unresolved reference: nip10Notes", + "at Cell In[5], line 8, column 14: Unresolved reference: NostrSignerSync", + "at Cell In[5], line 9, column 21: Unresolved reference: TextNoteEvent", + "at Cell In[5], line 12, column 1: Overload resolution ambiguity: ", + "public inline fun println(message: Any?): Unit defined in kotlin.io", + "public inline fun println(message: Boolean): Unit defined in kotlin.io", + "public inline fun println(message: Byte): Unit defined in kotlin.io", + "public inline fun println(message: Char): Unit defined in kotlin.io", + "public inline fun println(message: CharArray): Unit defined in kotlin.io", + "public inline fun println(message: Double): Unit defined in kotlin.io", + "public inline fun println(message: Float): Unit defined in kotlin.io", + "public inline fun println(message: Int): Unit defined in kotlin.io", + "public inline fun println(message: Long): Unit defined in kotlin.io", + "public inline fun println(message: Short): Unit defined in kotlin.io", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.JupyterCompilerImpl.compileSync(JupyterCompilerImpl.kt:208)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.InternalEvaluatorImpl.eval(InternalEvaluatorImpl.kt:126)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.CellExecutorImpl$execute$1$result$1.invoke(CellExecutorImpl.kt:80)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.CellExecutorImpl$execute$1$result$1.invoke(CellExecutorImpl.kt:78)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl.withHost(ReplForJupyterImpl.kt:791)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.CellExecutorImpl.execute-L4Nmkdk(CellExecutorImpl.kt:78)", + "\tat org.jetbrains.kotlinx.jupyter.repl.execution.CellExecutor$DefaultImpls.execute-L4Nmkdk$default(CellExecutor.kt:13)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl.evaluateUserCode-wNURfNM(ReplForJupyterImpl.kt:613)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl.evalExImpl(ReplForJupyterImpl.kt:471)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl.access$evalExImpl(ReplForJupyterImpl.kt:143)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl$evalEx$1.invoke(ReplForJupyterImpl.kt:464)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl$evalEx$1.invoke(ReplForJupyterImpl.kt:463)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl.withEvalContext(ReplForJupyterImpl.kt:444)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl.evalEx(ReplForJupyterImpl.kt:463)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor$processExecuteRequest$1$response$1$1.invoke(IdeCompatibleMessageRequestProcessor.kt:159)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor$processExecuteRequest$1$response$1$1.invoke(IdeCompatibleMessageRequestProcessor.kt:158)", + "\tat org.jetbrains.kotlinx.jupyter.streams.BlockingSubstitutionEngine.withDataSubstitution(SubstitutionEngine.kt:70)", + "\tat org.jetbrains.kotlinx.jupyter.streams.StreamSubstitutionManager.withSubstitutedStreams(StreamSubstitutionManager.kt:118)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor.withForkedIn(IdeCompatibleMessageRequestProcessor.kt:335)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor.access$withForkedIn(IdeCompatibleMessageRequestProcessor.kt:54)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor$evalWithIO$1$1.invoke(IdeCompatibleMessageRequestProcessor.kt:349)", + "\tat org.jetbrains.kotlinx.jupyter.streams.BlockingSubstitutionEngine.withDataSubstitution(SubstitutionEngine.kt:70)", + "\tat org.jetbrains.kotlinx.jupyter.streams.StreamSubstitutionManager.withSubstitutedStreams(StreamSubstitutionManager.kt:118)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor.withForkedErr(IdeCompatibleMessageRequestProcessor.kt:324)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor.access$withForkedErr(IdeCompatibleMessageRequestProcessor.kt:54)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor$evalWithIO$1.invoke(IdeCompatibleMessageRequestProcessor.kt:348)", + "\tat org.jetbrains.kotlinx.jupyter.streams.BlockingSubstitutionEngine.withDataSubstitution(SubstitutionEngine.kt:70)", + "\tat org.jetbrains.kotlinx.jupyter.streams.StreamSubstitutionManager.withSubstitutedStreams(StreamSubstitutionManager.kt:118)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor.withForkedOut(IdeCompatibleMessageRequestProcessor.kt:316)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor.evalWithIO(IdeCompatibleMessageRequestProcessor.kt:347)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor$processExecuteRequest$1$response$1.invoke(IdeCompatibleMessageRequestProcessor.kt:158)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor$processExecuteRequest$1$response$1.invoke(IdeCompatibleMessageRequestProcessor.kt:157)", + "\tat org.jetbrains.kotlinx.jupyter.execution.JupyterExecutorImpl$Task.execute(JupyterExecutorImpl.kt:41)", + "\tat org.jetbrains.kotlinx.jupyter.execution.JupyterExecutorImpl$executorThread$1.invoke(JupyterExecutorImpl.kt:83)", + "\tat org.jetbrains.kotlinx.jupyter.execution.JupyterExecutorImpl$executorThread$1.invoke(JupyterExecutorImpl.kt:80)", + "\tat kotlin.concurrent.ThreadsKt$thread$thread$1.run(Thread.kt:30)", + "" + ] + } + ], + "execution_count": 5 + }, + { + "metadata": {}, + "cell_type": "code", + "source": "", + "outputs": [], + "execution_count": null + } + ], + "metadata": { + "kernelspec": { + "display_name": "Kotlin", + "language": "kotlin", + "name": "kotlin" + }, + "language_info": { + "name": "kotlin", + "version": "1.9.23", + "mimetype": "text/x-kotlin", + "file_extension": ".kt", + "pygments_lexer": "kotlin", + "codemirror_mode": "text/x-kotlin", + "nbconvert_exporter": "" + }, + "ktnbPluginMetadata": { + "projectDependencies": [ + "Amethyst.amethyst.unitTest" + ], + "projectLibraries": false + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/LargeDBSignatureCheck.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/LargeDBSignatureCheck.kt index d86e208e23..b40314fd7d 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/LargeDBSignatureCheck.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/LargeDBSignatureCheck.kt @@ -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.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip01Core.verify import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertTrue @@ -42,7 +42,7 @@ class LargeDBSignatureCheck { val fullDBInputStream = getInstrumentation().context.assets.open("nostr_vitor_short.json") val eventArray = - EventMapper.mapper.readValue>( + JsonMapper.mapper.readValue>( InputStreamReader(fullDBInputStream), ) as List @@ -62,7 +62,7 @@ class LargeDBSignatureCheck { val fullDBInputStream = getInstrumentation().context.assets.open("nostr_vitor_startup_data.json") val eventArray = - EventMapper.mapper.readValue>( + JsonMapper.mapper.readValue>( GZIPInputStream(fullDBInputStream), ) as List diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/bloom/BloomFilter.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/bloom/BloomFilter.kt index 944d45e07b..30c9da492a 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/bloom/BloomFilter.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/bloom/BloomFilter.kt @@ -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 diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/lightning/LnInvoiceUtilTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/lightning/LnInvoiceUtilTest.kt index eca01e5575..1d1102c2b4 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/lightning/LnInvoiceUtilTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/lightning/LnInvoiceUtilTest.kt @@ -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 diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/EventSigCheck.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/EventSigCheck.kt index 2dc9213a1e..23a80683bf 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/EventSigCheck.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/EventSigCheck.kt @@ -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,7 +21,7 @@ package com.vitorpamplona.quartz.nip01Core import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import org.junit.Test import org.junit.runner.RunWith @@ -52,8 +52,8 @@ class EventSigCheck { @Test fun testUnicode2028and2029ShouldNotBeEscaped() { - val msg = EventMapper.mapper.readTree(payload1) - val event = EventMapper.fromJson(msg[2]) + val msg = JsonMapper.mapper.readTree(payload1) + val event = JsonMapper.fromJson(msg[2]) // Should pass event.checkSignature() diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/Nip01Test.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/Nip01Test.kt index 16acec1024..21431979e8 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/Nip01Test.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/Nip01Test.kt @@ -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 diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/crypto/Nip01CryptoTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/crypto/Nip01CryptoTest.kt index 8789248a53..6c0fbacb1a 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/crypto/Nip01CryptoTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/crypto/Nip01CryptoTest.kt @@ -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 diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt new file mode 100644 index 0000000000..628f24c343 --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt @@ -0,0 +1,104 @@ +/** + * 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.quartz.nip01Core.store.sqlite + +import android.content.Context +import android.database.sqlite.SQLiteConstraintException +import androidx.test.core.app.ApplicationProvider +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import junit.framework.TestCase +import junit.framework.TestCase.fail +import org.junit.After +import org.junit.Before +import org.junit.Test + +class AddressableTest { + private lateinit var db: EventStore + + val signer = NostrSignerSync() + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + db = EventStore(context, "test.db", relayUrl = "testUrl") + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun testReplacingAddressables() { + val time = TimeUtils.now() + val version1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) + val version2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) + val version3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) + + db.insert(version1) + + db.assertQuery(version1, Filter(ids = listOf(version1.id))) + + db.insert(version2) + + db.assertQuery(null, Filter(ids = listOf(version1.id))) + db.assertQuery(version2, Filter(ids = listOf(version2.id))) + + db.insert(version3) + + db.assertQuery(null, Filter(ids = listOf(version1.id))) + db.assertQuery(null, Filter(ids = listOf(version2.id))) + db.assertQuery(version3, Filter(ids = listOf(version3.id))) + } + + @Test + fun testBlockingOldAddressables() { + val time = TimeUtils.now() + val version1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) + val version2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) + val version3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) + + db.insert(version3) + + db.assertQuery(version3, Filter(ids = listOf(version3.id))) + + try { + db.insert(version2) + fail("It should not allow inserting an older version") + } catch (e: Exception) { + TestCase.assertTrue(e is SQLiteConstraintException) + } + + try { + db.insert(version1) + fail("It should not allow inserting an older version") + } catch (e: Exception) { + TestCase.assertTrue(e is SQLiteConstraintException) + } + + db.assertQuery(version3, Filter(ids = listOf(version3.id))) + db.assertQuery(null, Filter(ids = listOf(version2.id))) + db.assertQuery(null, Filter(ids = listOf(version1.id))) + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt new file mode 100644 index 0000000000..6dee855808 --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt @@ -0,0 +1,83 @@ +/** + * 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.quartz.nip01Core.store.sqlite + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import junit.framework.TestCase + +fun EventStore.assertQuery( + expected: Event?, + filter: Filter, +) { + val queryResult = query(filter) + val countResult = count(filter) + if (expected == null) { + TestCase.assertEquals(0, queryResult.size) + TestCase.assertEquals(0, countResult) + } else { + TestCase.assertEquals(1, queryResult.size) + TestCase.assertEquals(1, countResult) + TestCase.assertEquals(expected.toJson(), queryResult.first().toJson()) + } +} + +fun EventStore.assertQuery( + expected: List, + filter: Filter, +) { + val queryResult = query(filter) + val countResult = count(filter) + TestCase.assertEquals(expected.size, queryResult.size) + TestCase.assertEquals(expected.size, countResult) + expected.forEachIndexed { index, event -> + TestCase.assertEquals(event.toJson(), queryResult[index].toJson()) + } +} + +fun SQLiteEventStore.assertQuery( + expected: Event?, + filter: Filter, +) { + val queryResult = query(filter) + val countResult = count(filter) + if (expected == null) { + TestCase.assertEquals(0, queryResult.size) + TestCase.assertEquals(0, countResult) + } else { + TestCase.assertEquals(1, queryResult.size) + TestCase.assertEquals(1, countResult) + TestCase.assertEquals(expected.toJson(), queryResult.first().toJson()) + } +} + +fun SQLiteEventStore.assertQuery( + expected: List, + filter: Filter, +) { + val queryResult = query(filter) + val countResult = count(filter) + TestCase.assertEquals(expected.size, queryResult.size) + TestCase.assertEquals(expected.size, countResult) + expected.forEachIndexed { index, event -> + TestCase.assertEquals(event.toJson(), queryResult[index].toJson()) + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt new file mode 100644 index 0000000000..8aa2a7f3be --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt @@ -0,0 +1,207 @@ +/** + * 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.quartz.nip01Core.store.sqlite + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import junit.framework.TestCase.assertEquals +import org.junit.After +import org.junit.Before +import org.junit.Test + +class BasicTest { + private lateinit var db: SQLiteEventStore + + val signer = NostrSignerSync() + + companion object Companion { + val profile = + MetadataEvent( + id = "490d7439e530423f2540d4f2bdb73a0a2935f3df9e1f2a6f699a140c7db311fe", + pubKey = "70a9b3c312a6b83e476739bd29d60ca700da1d5b982cbca87b5f3d27d4038d67", + createdAt = 1740669816, + tags = + arrayOf( + arrayOf("alt", "User profile for Vitor"), + arrayOf("name", "Vitor"), + ), + content = "{\"name\":\"Vitor\"}", + sig = "977a6152199f17d103d8d56736ed1b7767054464cf9423d017c01c8cdd2344698f0a5e13da8dff98d01bb1f798837e3b6271e1fd1cac861bb90686f622ae6ef4", + ) + + val comment = + CommentEvent( + id = "fecb2ecf61a1433d417a784d10bd1e8ec19a916170a53ca8fb3a15fc666a6592", + pubKey = "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", + createdAt = 1747753115, + tags = + arrayOf( + arrayOf("alt", "Reply to geo:drt3n"), + arrayOf("I", "geo:drt3n"), + arrayOf("I", "geo:drt3"), + arrayOf("I", "geo:drt"), + arrayOf("I", "geo:dr"), + arrayOf("I", "geo:d"), + arrayOf("K", "geo"), + arrayOf("i", "geo:drt3n"), + arrayOf("i", "geo:drt3"), + arrayOf("i", "geo:drt"), + arrayOf("i", "geo:dr"), + arrayOf("i", "geo:d"), + arrayOf("k", "geo"), + ), + content = "testing", + sig = "12070e663272f1227c639fb834eb2122fc7bb995f4c49e55ebb1dfe2135ef7347d44810bacd2e64fd26b8826fd47d2800ce6c3d3b579bb3afe39088ffd4faa60", + ) + } + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + db = SQLiteEventStore(context, null) + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun testInsertDeleteEvent() { + val note = signer.sign(TextNoteEvent.build("test1")) + + db.insertEvent(note) + + db.assertQuery(note, Filter(ids = listOf(note.id))) + + db.delete(note.id) + + db.assertQuery(null, Filter(ids = listOf(note.id))) + + db.insertEvent(note) + + db.assertQuery(note, Filter(ids = listOf(note.id))) + } + + @Test + fun testEmptyFilter() { + val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = 1)) + val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = 2)) + + db.insertEvent(note1) + + db.assertQuery(note1, Filter()) + + db.insertEvent(note2) + + db.assertQuery(listOf(note2, note1), Filter()) + } + + @Test + fun testLimitFilter() { + val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = 1)) + val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = 2)) + val note3 = signer.sign(TextNoteEvent.build("test3", createdAt = 3)) + val note4 = signer.sign(TextNoteEvent.build("test4", createdAt = 4)) + + db.insertEvent(note1) + + db.assertQuery(note1, Filter(limit = 1)) + + db.insertEvent(note2) + db.insertEvent(note3) + db.insertEvent(note4) + + db.assertQuery(listOf(note4), Filter(limit = 1)) + } + + @Test + fun testPubkeyTag() { + db.insertEvent(comment) + db.insertEvent(profile) + + db.assertQuery( + comment, + Filter(authors = listOf(comment.pubKey), tags = mapOf("I" to listOf("geo:drt3n"))), + ) + } + + @Test + fun testTagOnly() { + db.insertEvent(comment) + db.insertEvent(profile) + + db.assertQuery(comment, Filter(tags = mapOf("I" to listOf("geo:drt3n")))) + } + + @Test + fun testTagWithSinceOnly() { + db.insertEvent(comment) + db.insertEvent(profile) + + db.assertQuery( + comment, + Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt - 1), + ) + db.assertQuery( + comment, + Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt), + ) + db.assertQuery( + null, + Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt + 1), + ) + } + + @Test + fun testTagWithUntilOnly() { + db.insertEvent(comment) + db.insertEvent(profile) + + db.assertQuery( + null, + Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt - 1), + ) + db.assertQuery( + comment, + Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt), + ) + db.assertQuery( + comment, + Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt + 1), + ) + } + + @Test + fun testTagWithUntilOnlyEmitting() { + db.insertEvent(comment) + db.insertEvent(profile) + + db.query(Filter(tags = mapOf("I" to listOf("geo:drt3n")))) { event -> + assertEquals(comment.toJson(), event.toJson()) + } + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt new file mode 100644 index 0000000000..fde0d95665 --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt @@ -0,0 +1,173 @@ +/** + * 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.quartz.nip01Core.store.sqlite + +import android.content.Context +import android.database.sqlite.SQLiteConstraintException +import androidx.test.core.app.ApplicationProvider +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import junit.framework.TestCase.fail +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class DeletionTest { + private lateinit var db: EventStore + + val signer = NostrSignerSync() + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + db = EventStore(context, null) + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun testInsertDeleteEvent() { + val note1 = signer.sign(TextNoteEvent.build("test1")) + val note2 = signer.sign(TextNoteEvent.build("test2")) + val note3 = signer.sign(TextNoteEvent.build("test3")) + + db.insert(note1) + db.insert(note2) + db.insert(note3) + + db.assertQuery(note1, Filter(ids = listOf(note1.id))) + db.assertQuery(note2, Filter(ids = listOf(note2.id))) + db.assertQuery(note3, Filter(ids = listOf(note3.id))) + + val deletion = signer.sign(DeletionEvent.build(listOf(note1))) + + db.insert(deletion) + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(note2, Filter(ids = listOf(note2.id))) + db.assertQuery(note3, Filter(ids = listOf(note3.id))) + + // trying to insert again should fail. + try { + db.insert(note1) + fail("Should not be able to insert a deleted event") + } catch (e: SQLiteConstraintException) { + assertEquals("blocked: a deletion event for this event id exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + } + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(note2, Filter(ids = listOf(note2.id))) + db.assertQuery(note3, Filter(ids = listOf(note3.id))) + } + + @Test + fun testInsertDeleteEventOfAddressable() { + val time = TimeUtils.now() + val note1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) + val note2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) + val note3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) + + db.insert(note1) + + db.assertQuery(note1, Filter(ids = listOf(note1.id))) + + db.insert(note2) + + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(note2, Filter(ids = listOf(note2.id))) + + db.insert(note3) + + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(null, Filter(ids = listOf(note2.id))) + db.assertQuery(note3, Filter(ids = listOf(note3.id))) + + val deletion = signer.sign(DeletionEvent.build(listOf(note1))) + + db.insert(deletion) + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(null, Filter(ids = listOf(note2.id))) + db.assertQuery(null, Filter(ids = listOf(note3.id))) + + // trying to insert again should fail. + try { + db.insert(note1) + fail("Should not be able to insert a deleted event") + } catch (e: SQLiteConstraintException) { + assertEquals("blocked: a deletion event for this event id exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + } + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(null, Filter(ids = listOf(note2.id))) + db.assertQuery(null, Filter(ids = listOf(note3.id))) + } + + @Test + fun testInsertDeleteEventOfAddressable2() { + val time = TimeUtils.now() + val note1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) + val note2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) + val note3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) + + db.insert(note1) + db.insert(note2) + db.insert(note3) + + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(null, Filter(ids = listOf(note2.id))) + db.assertQuery(note3, Filter(ids = listOf(note3.id))) + + val deletion = signer.sign(DeletionEvent.buildAddressOnly(listOf(note1))) + + db.insert(deletion) + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(null, Filter(ids = listOf(note2.id))) + db.assertQuery(null, Filter(ids = listOf(note3.id))) + + // trying to insert again should fail. + try { + db.insert(note1) + fail("Should not be able to insert a deleted event") + } catch (e: SQLiteConstraintException) { + assertEquals("blocked: a deletion event for this address exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + } + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(null, Filter(ids = listOf(note2.id))) + db.assertQuery(null, Filter(ids = listOf(note3.id))) + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt new file mode 100644 index 0000000000..9194df913b --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt @@ -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.quartz.nip01Core.store.sqlite + +import android.content.Context +import android.database.sqlite.SQLiteConstraintException +import androidx.test.core.app.ApplicationProvider +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils +import junit.framework.TestCase.assertTrue +import junit.framework.TestCase.fail +import org.junit.After +import org.junit.Before +import org.junit.Test + +class ExpirationTest { + private lateinit var db: EventStore + + val signer = NostrSignerSync() + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + db = EventStore(context, null, relayUrl = "testUrl") + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun testDeletingExpiredEvents() { + val time = TimeUtils.now() + + val noteSafe = + signer.sign( + TextNoteEvent.build("test1", createdAt = time + 1) { + expiration(time + 100) + }, + ) + + db.insert(noteSafe) + + val noteToExpire = + signer.sign( + TextNoteEvent.build("test1", createdAt = time + 1) { + expiration(time + 1) + }, + ) + + db.insert(noteToExpire) + + db.assertQuery(noteToExpire, Filter(ids = listOf(noteToExpire.id))) + + Thread.sleep(2000) + + db.deleteExpiredEvents() + + db.assertQuery(null, Filter(ids = listOf(noteToExpire.id))) + db.assertQuery(noteSafe, Filter(ids = listOf(noteSafe.id))) + } + + @Test + fun testInsertingExpiredEvents() { + val time = TimeUtils.now() + + val note1 = + signer.sign( + TextNoteEvent.build("test1", createdAt = time - 12) { + expiration(time - 10) + }, + ) + + try { + db.insert(note1) + fail("Should not be able to insert expired events") + } catch (e: Exception) { + assertTrue(e is SQLiteConstraintException) + } + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt new file mode 100644 index 0000000000..ac62d9ff33 --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt @@ -0,0 +1,104 @@ +/** + * 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.quartz.nip01Core.store.sqlite + +import android.content.Context +import android.database.sqlite.SQLiteException +import android.util.Log +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper +import com.vitorpamplona.quartz.nip40Expiration.isExpired +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.util.zip.GZIPInputStream +import kotlin.system.measureTimeMillis + +@RunWith(AndroidJUnit4::class) +class LargeDBTests { + companion object { + fun getEventDB(): List { + // This file includes duplicates + val fullDBInputStream = getInstrumentation().context.assets.open("nostr_vitor_startup_data.json") + + return JsonMapper.mapper.readValue>( + GZIPInputStream(fullDBInputStream), + ) + } + + val events = getEventDB().distinctBy { it.id }.filter { !it.isExpired() }.sortedBy { it.createdAt } + } + + private lateinit var db: EventStore + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + db = EventStore(context, "largeDBTest.db") + db.store.clearDB() + } + + @After + fun tearDown() { + db.store.clearDB() + db.close() + } + + @Test + fun insertHeavyEvent() { + events.first { it.id == "3f34b8cb682307ec11753de4669ce8948e95fd6fb360d79136446c5547fd235e" }.let { event -> + println(event.toJson()) + try { + val measure = + measureTimeMillis { + db.insert(event) + } + if (measure > 1) { + println("Inserted event ${event.id} of kind ${event.kind} in $measure ms") + } + } catch (e: SQLiteException) { + Log.w("LargeDBTests", "Error inserting event: ${e.message} for event: ${event.toJson()}") + } + } + } + + @Test + fun insertDatabase() { + events.forEach { event -> + try { + val measure = + measureTimeMillis { + db.insert(event) + } + if (measure > 1) { + println("Inserted event ${event.id} of kind ${event.kind} in $measure ms") + } + } catch (e: SQLiteException) { + Log.w("LargeDBTests", "Error inserting event: ${e.message} for event: ${event.toJson()}") + } + } + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt new file mode 100644 index 0000000000..0a85cfc75c --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt @@ -0,0 +1,104 @@ +/** + * 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.quartz.nip01Core.store.sqlite + +import android.content.Context +import android.database.sqlite.SQLiteConstraintException +import androidx.test.core.app.ApplicationProvider +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.utils.TimeUtils +import junit.framework.TestCase +import junit.framework.TestCase.fail +import org.junit.After +import org.junit.Before +import org.junit.Test + +class ReplaceableTest { + private lateinit var db: EventStore + + val signer = NostrSignerSync() + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + db = EventStore(context, null, relayUrl = "testUrl") + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun testReplacing() { + val time = TimeUtils.now() + val version1 = signer.sign(MetadataEvent.createNew("Vitor 1", createdAt = time)) + val version2 = signer.sign(MetadataEvent.createNew("Vitor 2", createdAt = time + 1)) + val version3 = signer.sign(MetadataEvent.createNew("Vitor 3", createdAt = time + 2)) + + db.insert(version1) + + db.assertQuery(version1, Filter(ids = listOf(version1.id))) + + db.insert(version2) + + db.assertQuery(null, Filter(ids = listOf(version1.id))) + db.assertQuery(version2, Filter(ids = listOf(version2.id))) + + db.insert(version3) + + db.assertQuery(null, Filter(ids = listOf(version1.id))) + db.assertQuery(null, Filter(ids = listOf(version2.id))) + db.assertQuery(version3, Filter(ids = listOf(version3.id))) + } + + @Test + fun testBlockingOldVersions() { + val time = TimeUtils.now() + val version1 = signer.sign(MetadataEvent.createNew("Vitor 1", createdAt = time)) + val version2 = signer.sign(MetadataEvent.createNew("Vitor 2", createdAt = time + 1)) + val version3 = signer.sign(MetadataEvent.createNew("Vitor 3", createdAt = time + 2)) + + db.insert(version3) + + db.assertQuery(version3, Filter(ids = listOf(version3.id))) + + try { + db.insert(version2) + fail("It should not allow inserting an older version") + } catch (e: Exception) { + TestCase.assertTrue(e is SQLiteConstraintException) + } + + try { + db.insert(version1) + fail("It should not allow inserting an older version") + } catch (e: Exception) { + TestCase.assertTrue(e is SQLiteConstraintException) + } + + db.assertQuery(version3, Filter(ids = listOf(version3.id))) + db.assertQuery(null, Filter(ids = listOf(version2.id))) + db.assertQuery(null, Filter(ids = listOf(version1.id))) + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt new file mode 100644 index 0000000000..578d6aeb76 --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt @@ -0,0 +1,90 @@ +/** + * 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.quartz.nip01Core.store.sqlite + +import android.content.Context +import android.database.sqlite.SQLiteConstraintException +import androidx.test.core.app.ApplicationProvider +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import junit.framework.TestCase.fail +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class RightToVanishTest { + private lateinit var db: EventStore + + val signer = NostrSignerSync() + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + db = EventStore(context, null, relayUrl = "testUrl") + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun testInsertDeleteEvent() { + val time = TimeUtils.now() + val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = time)) + val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = time + 1)) + val note3 = signer.sign(TextNoteEvent.build("test3", createdAt = time + 2)) + + db.insert(note1) + db.insert(note2) + db.insert(note3) + + db.assertQuery(note1, Filter(ids = listOf(note1.id))) + db.assertQuery(note2, Filter(ids = listOf(note2.id))) + db.assertQuery(note3, Filter(ids = listOf(note3.id))) + + val vanish = signer.sign(RequestToVanishEvent.build("testUrl", createdAt = time + 2)) + + db.insert(vanish) + + db.assertQuery(vanish, Filter(ids = listOf(vanish.id))) + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(null, Filter(ids = listOf(note2.id))) + db.assertQuery(note3, Filter(ids = listOf(note3.id))) + + // trying to insert again should fail. + try { + db.insert(note1) + fail("Should not be able to insert a deleted event") + } catch (e: SQLiteConstraintException) { + assertEquals("blocked: a request to vanish event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + } + + db.assertQuery(vanish, Filter(ids = listOf(vanish.id))) + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(null, Filter(ids = listOf(note2.id))) + db.assertQuery(note3, Filter(ids = listOf(note3.id))) + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt new file mode 100644 index 0000000000..f73a3372d0 --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt @@ -0,0 +1,108 @@ +/** + * 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.quartz.nip01Core.store.sqlite + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import org.junit.After +import org.junit.Before +import org.junit.Test + +class SearchTest { + private lateinit var db: SQLiteEventStore + + companion object Companion { + val profile = + MetadataEvent( + id = "490d7439e530423f2540d4f2bdb73a0a2935f3df9e1f2a6f699a140c7db311fe", + pubKey = "70a9b3c312a6b83e476739bd29d60ca700da1d5b982cbca87b5f3d27d4038d67", + createdAt = 1740669816, + tags = + arrayOf( + arrayOf("alt", "User profile for Vitor"), + arrayOf("name", "Vitor"), + ), + content = "{\"name\":\"Vitor\"}", + sig = "977a6152199f17d103d8d56736ed1b7767054464cf9423d017c01c8cdd2344698f0a5e13da8dff98d01bb1f798837e3b6271e1fd1cac861bb90686f622ae6ef4", + ) + + val comment = + CommentEvent( + id = "fecb2ecf61a1433d417a784d10bd1e8ec19a916170a53ca8fb3a15fc666a6592", + pubKey = "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", + createdAt = 1747753115, + tags = + arrayOf( + arrayOf("alt", "Reply to geo:drt3n"), + arrayOf("I", "geo:drt3n"), + arrayOf("I", "geo:drt3"), + arrayOf("I", "geo:drt"), + arrayOf("I", "geo:dr"), + arrayOf("I", "geo:d"), + arrayOf("K", "geo"), + arrayOf("i", "geo:drt3n"), + arrayOf("i", "geo:drt3"), + arrayOf("i", "geo:drt"), + arrayOf("i", "geo:dr"), + arrayOf("i", "geo:d"), + arrayOf("k", "geo"), + ), + content = "testing", + sig = "12070e663272f1227c639fb834eb2122fc7bb995f4c49e55ebb1dfe2135ef7347d44810bacd2e64fd26b8826fd47d2800ce6c3d3b579bb3afe39088ffd4faa60", + ) + } + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + db = SQLiteEventStore(context, null) + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun testTagWithSearch() { + db.insertEvent(comment) + db.insertEvent(profile) + + db.assertQuery(null, Filter(search = "testing1")) + db.assertQuery(comment, Filter(search = "testing")) + db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), search = "testing")) + db.assertQuery(null, Filter(kinds = listOf(TextNoteEvent.KIND), search = "testing")) + + db.delete(comment.id) + + db.assertQuery(null, Filter(search = "testing")) + db.assertQuery(null, Filter(kinds = listOf(CommentEvent.KIND), search = "testing")) + + db.insertEvent(comment) + + db.assertQuery(comment, Filter(search = "testing")) + db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), search = "testing")) + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip03Timestamp/ots/OtsTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip03Timestamp/ots/OtsTest.kt index e33bf3d6b1..0cbd18b877 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip03Timestamp/ots/OtsTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip03Timestamp/ots/OtsTest.kt @@ -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 @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertNotNull import junit.framework.TestCase.fail +import kotlinx.coroutines.runBlocking import org.junit.Assert import org.junit.Test import java.util.concurrent.CountDownLatch @@ -51,14 +52,14 @@ class OtsTest { @Test fun verifyNostrEvent2() { - val ots = Event.Companion.fromJson(otsEvent2) as OtsEvent + val ots = Event.fromJson(otsEvent2) as OtsEvent println(resolver.info(ots.otsByteArray())) assertEquals(1706322179L, ots.verify(resolver)) } @Test fun verifyNostrPendingEvent() { - val ots = Event.Companion.fromJson(otsPendingEvent) as OtsEvent + val ots = Event.fromJson(otsPendingEvent) as OtsEvent println(resolver.info(ots.otsByteArray())) assertEquals(null, ots.verify(resolver)) @@ -74,17 +75,13 @@ class OtsTest { val signer = NostrSignerInternal(KeyPair()) - var newOts: OtsEvent? = null val countDownLatch = CountDownLatch(1) - signer.sign(OtsEvent.build(eventId, upgraded!!)) { - newOts = it - countDownLatch.countDown() - } + val newOts = runBlocking { signer.sign(OtsEvent.build(eventId, upgraded!!)) } Assert.assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - println(newOts!!.toJson()) + println(newOts.toJson()) println(resolver.info(newOts.otsByteArray())) assertEquals(1708879025L, newOts.verify(resolver)) @@ -93,18 +90,17 @@ class OtsTest { @Test fun createOTSEventAndVerify() { val signer = NostrSignerInternal(KeyPair()) - var ots: OtsEvent? = null val countDownLatch = CountDownLatch(1) - signer.sign(OtsEvent.build(otsEvent2Digest, OtsEvent.stamp(otsEvent2Digest, resolver))) { - ots = it - countDownLatch.countDown() - } + val ots = + runBlocking { + signer.sign(OtsEvent.build(otsEvent2Digest, OtsEvent.stamp(otsEvent2Digest, resolver))) + } Assert.assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - println(ots!!.toJson()) + println(ots.toJson()) println(resolver.info(ots.otsByteArray())) assertEquals(null, ots.verify(resolver)) diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip04Dm/EncryptionTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip04Dm/EncryptionTest.kt index b79267bf31..5da5cc912d 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip04Dm/EncryptionTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip04Dm/EncryptionTest.kt @@ -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 diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip04Dm/Nip04Test.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip04Dm/Nip04Test.kt index 29e0acfc8f..01a16c817f 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip04Dm/Nip04Test.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip04Dm/Nip04Test.kt @@ -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 diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip32SeedDerivationTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip32SeedDerivationTest.kt index 9d093fd3b1..15f2ef3608 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip32SeedDerivationTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip32SeedDerivationTest.kt @@ -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 diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip39KeyPathTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip39KeyPathTest.kt index fb738d6030..060a094806 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip39KeyPathTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip39KeyPathTest.kt @@ -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 diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip39MnemonicsTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip39MnemonicsTest.kt index 5ffb1a116c..ebdafca34c 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip39MnemonicsTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip39MnemonicsTest.kt @@ -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 diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip06KeyDerivation/Nip06Test.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip06KeyDerivation/Nip06Test.kt index b8eee92b22..1cdc0f42d0 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip06KeyDerivation/Nip06Test.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip06KeyDerivation/Nip06Test.kt @@ -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 diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip10Notes/CitationTests.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip10Notes/CitationTests.kt index 749d5028e8..eee4cb2933 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip10Notes/CitationTests.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip10Notes/CitationTests.kt @@ -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 diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip10Notes/ThreadingTests.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip10Notes/ThreadingTests.kt index 3a8888f004..3e10f561dc 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip10Notes/ThreadingTests.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip10Notes/ThreadingTests.kt @@ -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,6 +21,7 @@ package com.vitorpamplona.quartz.nip10Notes import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag @@ -186,7 +187,7 @@ class ThreadingTests { val taggedUsers = listOf( - PTag("4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0", "wss://goiaba.com"), + PTag("4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0", RelayUrlNormalizer.normalize("wss://goiaba.com")), PTag("534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec"), PTag("77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7"), ) @@ -249,7 +250,7 @@ class ThreadingTests { val taggedUsers = listOf( - PTag("534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec", "wss://banana.com"), + PTag("534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec", RelayUrlNormalizer.normalize("wss://banana.com")), ) assertEquals(taggedUsers, note.taggedUsers()) @@ -302,7 +303,7 @@ class ThreadingTests { val taggedUsers = listOf( - PTag("534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec", "wss://banana.com"), + PTag("534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec", RelayUrlNormalizer.normalize("wss://banana.com")), ) assertEquals(taggedUsers, note.taggedUsers()) diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip13Pow/PoWMinerTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip13Pow/PoWMinerTest.kt index 80de76babe..9010ab5e40 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip13Pow/PoWMinerTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip13Pow/PoWMinerTest.kt @@ -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 diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip13Pow/PoWRankEvaluatorTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip13Pow/PoWRankEvaluatorTest.kt index 2ced5d1296..9b8ff36e34 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip13Pow/PoWRankEvaluatorTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip13Pow/PoWRankEvaluatorTest.kt @@ -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 diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip17Dm/AESGCMTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip17Dm/AESGCMTest.kt index ce15430c9c..2d3eb66c14 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip17Dm/AESGCMTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip17Dm/AESGCMTest.kt @@ -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 diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip17Dm/ChatroomKeyTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip17Dm/ChatroomKeyTest.kt index 288ec59360..b7816fd119 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip17Dm/ChatroomKeyTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip17Dm/ChatroomKeyTest.kt @@ -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 diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip19Bech32/NIP19EmbedTests.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip19Bech32/NIP19EmbedTests.kt index 7df8c7a050..347ecf81f9 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip19Bech32/NIP19EmbedTests.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip19Bech32/NIP19EmbedTests.kt @@ -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,7 +22,6 @@ package com.vitorpamplona.quartz.nip19Bech32 import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent -import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal @@ -33,6 +32,7 @@ import com.vitorpamplona.quartz.utils.Hex import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertNotNull import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.runBlocking import org.junit.Assert import org.junit.Test import org.junit.runner.RunWith @@ -48,18 +48,12 @@ class NIP19EmbedTests { KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")), ) - var textNote: Event? = null - - val countDownLatch = CountDownLatch(1) - - signer.sign( - TextNoteEvent.build("I like this. It could solve the ninvite problem in #1062, and it seems like it could be applied very broadly to limit the spread of events that shouldn't stand on their own or need to be private. The one question I have is how long are these embeds? If it's 50 lines of text, that breaks the human readable (or at least parseable) requirement of kind 1s. Also, encoding json in a tlv is silly, we should at least use the tlv to reduce the payload size."), - ) { - textNote = it - countDownLatch.countDown() - } - - Assert.assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + val textNote: TextNoteEvent = + runBlocking { + signer.sign( + TextNoteEvent.build("I like this. It could solve the ninvite problem in #1062, and it seems like it could be applied very broadly to limit the spread of events that shouldn't stand on their own or need to be private. The one question I have is how long are these embeds? If it's 50 lines of text, that breaks the human readable (or at least parseable) requirement of kind 1s. Also, encoding json in a tlv is silly, we should at least use the tlv to reduce the payload size."), + ) + } assertNotNull(textNote) @@ -81,29 +75,23 @@ class NIP19EmbedTests { KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")), ) - var eyeglassesPrescriptionEvent: Event? = null - - val countDownLatch = CountDownLatch(1) - - signer.sign(FhirResourceEvent.build(visionPrescriptionFhir)) { - eyeglassesPrescriptionEvent = it - countDownLatch.countDown() - } - - Assert.assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + val eyeglassesPrescriptionEvent = + runBlocking { + signer.sign(FhirResourceEvent.build(visionPrescriptionFhir)) + } assertNotNull(eyeglassesPrescriptionEvent) - val bech32 = NEmbed.create(eyeglassesPrescriptionEvent!!) + val bech32 = NEmbed.create(eyeglassesPrescriptionEvent) - println(eyeglassesPrescriptionEvent!!.toJson()) + println(eyeglassesPrescriptionEvent.toJson()) println(bech32) val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event assertTrue(decodedNote.verify()) - assertEquals(eyeglassesPrescriptionEvent!!.toJson(), decodedNote.toJson()) + assertEquals(eyeglassesPrescriptionEvent.toJson(), decodedNote.toJson()) } @Test @@ -113,29 +101,27 @@ class NIP19EmbedTests { KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")), ) - var eyeglassesPrescriptionEvent: Event? = null - val countDownLatch = CountDownLatch(1) - signer.sign(FhirResourceEvent.build(visionPrescriptionBundle)) { - eyeglassesPrescriptionEvent = it - countDownLatch.countDown() - } + val eyeglassesPrescriptionEvent = + runBlocking { + signer.sign(FhirResourceEvent.build(visionPrescriptionBundle)) + } Assert.assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) assertNotNull(eyeglassesPrescriptionEvent) - val bech32 = NEmbed.create(eyeglassesPrescriptionEvent!!) + val bech32 = NEmbed.create(eyeglassesPrescriptionEvent) - println(eyeglassesPrescriptionEvent!!.toJson()) + println(eyeglassesPrescriptionEvent.toJson()) println(bech32) val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event assertTrue(decodedNote.verify()) - assertEquals(eyeglassesPrescriptionEvent!!.toJson(), decodedNote.toJson()) + assertEquals(eyeglassesPrescriptionEvent.toJson(), decodedNote.toJson()) } @Test @@ -145,29 +131,23 @@ class NIP19EmbedTests { KeyPair(decodePrivateKeyAsHexOrNull("nsec1arn3jlxv20y76n8ek8ydecy9ga06rl7aw8evznjylc3ap00hwkvqx4vvy6")!!.hexToByteArray()), ) - var eyeglassesPrescriptionEvent: Event? = null - - val countDownLatch = CountDownLatch(1) - - signer.sign(FhirResourceEvent.build(visionPrescriptionBundle2)) { - eyeglassesPrescriptionEvent = it - countDownLatch.countDown() - } - - Assert.assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + val eyeglassesPrescriptionEvent = + runBlocking { + signer.sign(FhirResourceEvent.build(visionPrescriptionBundle2)) + } assertNotNull(eyeglassesPrescriptionEvent) - val bech32 = NEmbed.create(eyeglassesPrescriptionEvent!!) + val bech32 = NEmbed.create(eyeglassesPrescriptionEvent) - println(eyeglassesPrescriptionEvent!!.toJson()) + println(eyeglassesPrescriptionEvent.toJson()) println(bech32) val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event assertTrue(decodedNote.verify()) - assertEquals(eyeglassesPrescriptionEvent!!.toJson(), decodedNote.toJson()) + assertEquals(eyeglassesPrescriptionEvent.toJson(), decodedNote.toJson()) } @Test diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip22Comments/GeolocatedComments.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip22Comments/GeolocatedComments.kt new file mode 100644 index 0000000000..6f0707638d --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip22Comments/GeolocatedComments.kt @@ -0,0 +1,96 @@ +/** + * 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.quartz.nip22Comments + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.verify +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId +import com.vitorpamplona.quartz.nip73ExternalIds.location.geohashedScope +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class GeolocatedComments { + private val privateKey = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray() + private val signer = NostrSignerSync(KeyPair(privateKey)) + + val event = + CommentEvent( + "fecb2ecf61a1433d417a784d10bd1e8ec19a916170a53ca8fb3a15fc666a6592", + "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", + 1747753115, + arrayOf( + arrayOf("alt", "Reply to geo:drt3n"), + arrayOf("I", "geo:drt3n"), + arrayOf("I", "geo:drt3"), + arrayOf("I", "geo:drt"), + arrayOf("I", "geo:dr"), + arrayOf("I", "geo:d"), + arrayOf("K", "geo"), + arrayOf("i", "geo:drt3n"), + arrayOf("i", "geo:drt3"), + arrayOf("i", "geo:drt"), + arrayOf("i", "geo:dr"), + arrayOf("i", "geo:d"), + arrayOf("k", "geo"), + ), + "testing", + "12070e663272f1227c639fb834eb2122fc7bb995f4c49e55ebb1dfe2135ef7347d44810bacd2e64fd26b8826fd47d2800ce6c3d3b579bb3afe39088ffd4faa60", + ) + + @Test + fun verifyEvent() { + assertTrue(event.verify()) + } + + @Test + fun testScopes() { + assertEquals("drt3n", event.geohashedScope()) + assertTrue(event.isTaggedScope("drt3n", GeohashId::match)) + assertTrue(event.isTaggedScope(GeohashId.toScope("drt3n"))) + assertFalse(event.isTaggedScope("drt3n")) + } + + @Test + fun testCreation() { + val event = + signer.sign( + CommentEvent.replyExternalIdentity( + "Message", + GeohashId("drt3n"), + ), + ) + + assertEquals("drt3n", event!!.geohashedScope()) + assertTrue(event.isTaggedScope(GeohashId.toScope("drt3n"))) + assertFalse(event.isTaggedScope("drt3n")) + + assertTrue(event.hasScopeKind(GeohashId.KIND)) + assertTrue(event.hasRootScopeKind(GeohashId.KIND)) + assertTrue(event.hasReplyScopeKind(GeohashId.KIND)) + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v1Test.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v1Test.kt index 611c9abf5f..4ec924773d 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v1Test.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v1Test.kt @@ -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 diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v2Test.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v2Test.kt index dba7e58cfb..e0dcddbfe3 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v2Test.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v2Test.kt @@ -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 diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip44Encryption/TestPackageClasses.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip44Encryption/TestPackageClasses.kt index 8b373342f5..40ee511f10 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip44Encryption/TestPackageClasses.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip44Encryption/TestPackageClasses.kt @@ -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 diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip46RemoteSigner/Nip46Test.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip46RemoteSigner/Nip46Test.kt index 2050af360f..61794b0964 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip46RemoteSigner/Nip46Test.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip46RemoteSigner/Nip46Test.kt @@ -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,14 +23,16 @@ package com.vitorpamplona.quartz.nip46RemoteSigner import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip02FollowList.ReadWrite +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.utils.TimeUtils import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.runBlocking import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith -import java.util.concurrent.CountDownLatch @RunWith(AndroidJUnit4::class) internal class Nip46Test { @@ -39,216 +41,222 @@ internal class Nip46Test { val peer = NostrSignerInternal(KeyPair()) val dummyEvent = - Event( + EventTemplate( + createdAt = TimeUtils.now(), + kind = 1, + tags = emptyArray(), + content = "test", + ) + + val dummyEventSigned = + TextNoteEvent( id = "", pubKey = "", createdAt = TimeUtils.now(), - kind = 1, tags = emptyArray(), content = "test", sig = "", ) - fun encodeDecodeEvent(req: T): T { - var countDownLatch = CountDownLatch(1) - var eventStr: String? = null + suspend fun encodeDecodeEvent(req: T): T { + val eventStr = NostrConnectEvent.create(req, remoteKey.pubKey, signer).toJson() - NostrConnectEvent.create(req, remoteKey.pubKey, signer) { - eventStr = it.toJson() - countDownLatch.countDown() + return (Event.fromJson(eventStr) as NostrConnectEvent).decryptMessage(signer) as T + } + + @Test + fun signEncoder() = + runBlocking { + val expected = BunkerRequestSign(event = dummyEvent) + val actual = encodeDecodeEvent(expected) + + assertEquals(BunkerRequestSign.METHOD_NAME, actual.method) + assertEquals(expected.id, actual.id) + assertEquals(dummyEvent.createdAt, actual.event.createdAt) } - countDownLatch.await() + @Test + fun connectEncoder() = + runBlocking { + val expected = BunkerRequestConnect(remoteKey = remoteKey.pubKey) + val actual = encodeDecodeEvent(expected) - countDownLatch = CountDownLatch(1) - var innerMessage: T? = null - - (Event.fromJson(eventStr!!) as NostrConnectEvent).plainContent(signer) { - innerMessage = it as T - countDownLatch.countDown() + assertEquals(expected.method, actual.method) + assertEquals(expected.id, actual.id) } - countDownLatch.await() + @Test + fun pingEncoder() = + runBlocking { + val expected = BunkerRequestPing() + val actual = encodeDecodeEvent(expected) - return innerMessage!! - } + assertEquals(expected.method, actual.method) + assertEquals(expected.id, actual.id) + } @Test - fun signEncoder() { - val expected = BunkerRequestSign(event = dummyEvent) - val actual = encodeDecodeEvent(expected) + fun getPubkeyEncoder() = + runBlocking { + val expected = BunkerRequestGetPublicKey() + val actual = encodeDecodeEvent(expected) - assertEquals(BunkerRequestSign.METHOD_NAME, actual.method) - assertEquals(expected.id, actual.id) - assertEquals(dummyEvent.id, actual.event.id) - } + assertEquals(expected.method, actual.method) + assertEquals(expected.id, actual.id) + } @Test - fun connectEncoder() { - val expected = BunkerRequestConnect(remoteKey = remoteKey.pubKey) - val actual = encodeDecodeEvent(expected) + fun getRelaysEncoder() = + runBlocking { + val expected = BunkerRequestGetRelays() + val actual = encodeDecodeEvent(expected) - assertEquals(expected.method, actual.method) - assertEquals(expected.id, actual.id) - } + assertEquals(expected.method, actual.method) + assertEquals(expected.id, actual.id) + } @Test - fun pingEncoder() { - val expected = BunkerRequestPing() - val actual = encodeDecodeEvent(expected) + fun testNip04Encrypt() = + runBlocking { + val expected = BunkerRequestNip04Encrypt(pubKey = peer.pubKey, message = "Test") + val actual = encodeDecodeEvent(expected) - assertEquals(expected.method, actual.method) - assertEquals(expected.id, actual.id) - } + assertEquals(expected.method, actual.method) + assertEquals(expected.id, actual.id) + assertEquals(expected.pubKey, actual.pubKey) + assertEquals(expected.message, actual.message) + } @Test - fun getPubkeyEncoder() { - val expected = BunkerRequestGetPublicKey() - val actual = encodeDecodeEvent(expected) + fun testNip44Encrypt() = + runBlocking { + val expected = BunkerRequestNip44Encrypt(pubKey = peer.pubKey, message = "Test") + val actual = encodeDecodeEvent(expected) - assertEquals(expected.method, actual.method) - assertEquals(expected.id, actual.id) - } + assertEquals(expected.method, actual.method) + assertEquals(expected.id, actual.id) + assertEquals(expected.pubKey, actual.pubKey) + assertEquals(expected.message, actual.message) + } @Test - fun getRelaysEncoder() { - val expected = BunkerRequestGetRelays() - val actual = encodeDecodeEvent(expected) + fun testNip04Decrypt() = + runBlocking { + val expected = BunkerRequestNip04Decrypt(pubKey = peer.pubKey, ciphertext = "Test") + val actual = encodeDecodeEvent(expected) - assertEquals(expected.method, actual.method) - assertEquals(expected.id, actual.id) - } + assertEquals(expected.method, actual.method) + assertEquals(expected.id, actual.id) + assertEquals(expected.pubKey, actual.pubKey) + assertEquals(expected.ciphertext, actual.ciphertext) + } @Test - fun testNip04Encrypt() { - val expected = BunkerRequestNip04Encrypt(pubKey = peer.pubKey, message = "Test") - val actual = encodeDecodeEvent(expected) + fun testNip44Decrypt() = + runBlocking { + val expected = BunkerRequestNip44Decrypt(pubKey = peer.pubKey, ciphertext = "Test") + val actual = encodeDecodeEvent(expected) - assertEquals(expected.method, actual.method) - assertEquals(expected.id, actual.id) - assertEquals(expected.pubKey, actual.pubKey) - assertEquals(expected.message, actual.message) - } - - @Test - fun testNip44Encrypt() { - val expected = BunkerRequestNip44Encrypt(pubKey = peer.pubKey, message = "Test") - val actual = encodeDecodeEvent(expected) - - assertEquals(expected.method, actual.method) - assertEquals(expected.id, actual.id) - assertEquals(expected.pubKey, actual.pubKey) - assertEquals(expected.message, actual.message) - } - - @Test - fun testNip04Decrypt() { - val expected = BunkerRequestNip04Decrypt(pubKey = peer.pubKey, ciphertext = "Test") - val actual = encodeDecodeEvent(expected) - - assertEquals(expected.method, actual.method) - assertEquals(expected.id, actual.id) - assertEquals(expected.pubKey, actual.pubKey) - assertEquals(expected.ciphertext, actual.ciphertext) - } - - @Test - fun testNip44Decrypt() { - val expected = BunkerRequestNip44Decrypt(pubKey = peer.pubKey, ciphertext = "Test") - val actual = encodeDecodeEvent(expected) - - assertEquals(expected.method, actual.method) - assertEquals(expected.id, actual.id) - assertEquals(expected.pubKey, actual.pubKey) - assertEquals(expected.ciphertext, actual.ciphertext) - } + assertEquals(expected.method, actual.method) + assertEquals(expected.id, actual.id) + assertEquals(expected.pubKey, actual.pubKey) + assertEquals(expected.ciphertext, actual.ciphertext) + } // Responses @Test - fun testAckResponse() { - val expected = BunkerResponseAck() - val actual = encodeDecodeEvent(expected) + fun testAckResponse() = + runBlocking { + val expected = BunkerResponseAck() + val actual = encodeDecodeEvent(expected) - assertEquals(expected.id, actual.id) - assertEquals(expected.result, actual.result) - assertEquals(expected.error, actual.error) - } + assertEquals(expected.id, actual.id) + assertEquals(expected.result, actual.result) + assertEquals(expected.error, actual.error) + } @Test - fun testPongResponse() { - val expected = BunkerResponsePong() - val actual = encodeDecodeEvent(expected) + fun testPongResponse() = + runBlocking { + val expected = BunkerResponsePong() + val actual = encodeDecodeEvent(expected) - assertEquals(expected.id, actual.id) - assertEquals(expected.result, actual.result) - assertEquals(expected.error, actual.error) - } + assertEquals(expected.id, actual.id) + assertEquals(expected.result, actual.result) + assertEquals(expected.error, actual.error) + } @Test - fun testErrorResponse() { - val expected = BunkerResponseError(error = "Error") - val actual = encodeDecodeEvent(expected) + fun testErrorResponse() = + runBlocking { + val expected = BunkerResponseError(error = "Error") + val actual = encodeDecodeEvent(expected) - assertEquals(expected.id, actual.id) - assertEquals(expected.result, actual.result) - assertEquals(expected.error, actual.error) - } + assertEquals(expected.id, actual.id) + assertEquals(expected.result, actual.result) + assertEquals(expected.error, actual.error) + } @Test - fun testEventResponse() { - val expected = BunkerResponseEvent(event = dummyEvent) - val actual = encodeDecodeEvent(expected) + fun testEventResponse() = + runBlocking { + val expected = BunkerResponseEvent(event = dummyEventSigned) + val actual = encodeDecodeEvent(expected) - assertEquals(expected.id, actual.id) - assertEquals(expected.result, actual.result) - assertEquals(expected.error, actual.error) - assertEquals(dummyEvent.id, actual.event.id) - } + assertEquals(expected.id, actual.id) + assertEquals(expected.result, actual.result) + assertEquals(expected.error, actual.error) + assertEquals(dummyEventSigned.id, actual.event.id) + } @Test - fun testPubkeyResponse() { - val expected = BunkerResponsePublicKey(pubkey = peer.pubKey) - val actual = encodeDecodeEvent(expected) + fun testPubkeyResponse() = + runBlocking { + val expected = BunkerResponsePublicKey(pubkey = peer.pubKey) + val actual = encodeDecodeEvent(expected) - assertEquals(expected.id, actual.id) - assertEquals(expected.result, actual.result) - assertEquals(expected.error, actual.error) - assertEquals(expected.pubkey, actual.pubkey) - } + assertEquals(expected.id, actual.id) + assertEquals(expected.result, actual.result) + assertEquals(expected.error, actual.error) + assertEquals(expected.pubkey, actual.pubkey) + } @Test - fun testRelaysResponse() { - val expected = BunkerResponseGetRelays(relays = mapOf("url" to ReadWrite(true, false))) - val actual = encodeDecodeEvent(expected) + fun testRelaysResponse() = + runBlocking { + val expected = BunkerResponseGetRelays(relays = mapOf("url" to ReadWrite(true, false))) + val actual = encodeDecodeEvent(expected) - assertEquals(expected.id, actual.id) - assertEquals(expected.result, actual.result) - assertEquals(expected.error, actual.error) - assertEquals(expected.relays["url"], actual.relays["url"]) - } + assertEquals(expected.id, actual.id) + assertEquals(expected.result, actual.result) + assertEquals(expected.error, actual.error) + assertEquals(expected.relays["url"], actual.relays["url"]) + } @Test @Ignore("Impossible to recreate the class since there are no hints on the json") - fun testDecryptResponse() { - val expected = BunkerResponseDecrypt(plaintext = "Test") - val actual = encodeDecodeEvent(expected) + fun testDecryptResponse() = + runBlocking { + val expected = BunkerResponseDecrypt(plaintext = "Test") + val actual = encodeDecodeEvent(expected) - assertEquals(expected.id, actual.id) - assertEquals(expected.result, actual.result) - assertEquals(expected.error, actual.error) - assertEquals(expected.plaintext, actual.plaintext) - } + assertEquals(expected.id, actual.id) + assertEquals(expected.result, actual.result) + assertEquals(expected.error, actual.error) + assertEquals(expected.plaintext, actual.plaintext) + } @Test @Ignore("Impossible to recreate the class since there are no hints on the json") - fun testEncryptResponse() { - val expected = BunkerResponseEncrypt(ciphertext = "Test") - val actual = encodeDecodeEvent(expected) + fun testEncryptResponse() = + runBlocking { + val expected = BunkerResponseEncrypt(ciphertext = "Test") + val actual = encodeDecodeEvent(expected) - assertEquals(expected.id, actual.id) - assertEquals(expected.result, actual.result) - assertEquals(expected.error, actual.error) - assertEquals(expected.ciphertext, actual.ciphertext) - } + assertEquals(expected.id, actual.id) + assertEquals(expected.result, actual.result) + assertEquals(expected.error, actual.error) + assertEquals(expected.ciphertext, actual.ciphertext) + } } diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip49PrivKeyEnc/NIP49Test.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip49PrivKeyEnc/NIP49Test.kt index 8c39bf8654..514cf3e2e2 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip49PrivKeyEnc/NIP49Test.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip49PrivKeyEnc/NIP49Test.kt @@ -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 diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip55AndroidSigner/SignStringTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip55AndroidSigner/SignStringTest.kt index cbf2311167..9b907c6a70 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip55AndroidSigner/SignStringTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip55AndroidSigner/SignStringTest.kt @@ -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 diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapTests.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapTests.kt index eb696ff7a2..35f720ff30 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapTests.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapTests.kt @@ -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,13 +24,14 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.quartz.nip01Core.core.Event 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.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip57Zaps.PrivateZapEncryption.Companion.createEncryptionPrivateKey -import com.vitorpamplona.quartz.nip59GiftWraps.wait1SecondForResult import com.vitorpamplona.quartz.utils.Hex import junit.framework.TestCase.assertNotNull import junit.framework.TestCase.fail +import kotlinx.coroutines.runBlocking import org.junit.Test import org.junit.runner.RunWith @@ -39,7 +40,7 @@ class PrivateZapTests { @Test fun testPollZap() { val poll = - EventMapper.fromJson( + JsonMapper.fromJson( """{ "content": "New poll \n\n #zappoll", "created_at": 1682440713, @@ -89,42 +90,35 @@ class PrivateZapTests { ), ) - var resultPrivateZap: Event? = null - - wait1SecondForResult { onDone -> - LnZapRequestEvent.create( - originalNote = poll, - relays = setOf("wss://relay.damus.io/"), - signer = loggedIn, - pollOption = 0, - message = "", - zapType = LnZapEvent.ZapType.PRIVATE, - toUserPubHex = null, - ) { privateZapRequest -> - val recepientPK = privateZapRequest.zappedAuthor().firstOrNull() - val recepientPost = privateZapRequest.zappedPost().firstOrNull() - - if (recepientPK != null && recepientPost != null) { - val privateKey = - createEncryptionPrivateKey( - loggedIn.keyPair.privKey!!.toHexKey(), - recepientPost, - privateZapRequest.createdAt, - ) - val decodedPrivateZap = privateZapRequest.getPrivateZapEvent(privateKey, recepientPK) - - println(decodedPrivateZap?.toJson()) - - resultPrivateZap = decodedPrivateZap - - onDone() - } else { - fail("Should not be null") - } + val privateZapRequest = + runBlocking { + LnZapRequestEvent.create( + zappedEvent = poll, + relays = setOf(RelayUrlNormalizer.normalize("wss://relay.damus.io/")), + signer = loggedIn, + pollOption = 0, + message = "", + zapType = LnZapEvent.ZapType.PRIVATE, + toUserPubHex = null, + ) } - } - assertNotNull(resultPrivateZap) + val recepientPK = privateZapRequest.zappedAuthor().firstOrNull() + val recepientPost = privateZapRequest.zappedPost().firstOrNull() + + if (recepientPK != null && recepientPost != null) { + val privateKey = + createEncryptionPrivateKey( + loggedIn.keyPair.privKey!!.toHexKey(), + recepientPost, + privateZapRequest.createdAt, + ) + val decodedPrivateZap = PrivateZapRequestBuilder().decryptAnonTag(privateZapRequest.getAnonTag(), privateKey, recepientPK) + + assertNotNull(decodedPrivateZap) + } else { + fail("Should not be null") + } } @Test @@ -156,41 +150,39 @@ class PrivateZapTests { KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")), ) - var resultPrivateZap: Event? = null - - wait1SecondForResult { onDone -> - LnZapRequestEvent.create( - originalNote = textNote, - relays = setOf("wss://relay.damus.io/", "wss://relay.damus2.io/", "wss://relay.damus3.io/"), - signer = loggedIn, - pollOption = null, - message = "test", - zapType = LnZapEvent.ZapType.PRIVATE, - toUserPubHex = null, - ) { privateZapRequest -> - val recepientPK = privateZapRequest.zappedAuthor().firstOrNull() - val recepientPost = privateZapRequest.zappedPost().firstOrNull() - - if (recepientPK != null && recepientPost != null) { - val privateKey = - createEncryptionPrivateKey( - loggedIn.keyPair.privKey!!.toHexKey(), - recepientPost, - privateZapRequest.createdAt, - ) - val decodedPrivateZap = privateZapRequest.getPrivateZapEvent(privateKey, recepientPK) - - println(decodedPrivateZap?.toJson()) - - resultPrivateZap = decodedPrivateZap - - onDone() - } else { - fail("Should not be null") - } + val privateZapRequest = + runBlocking { + LnZapRequestEvent.create( + zappedEvent = textNote, + relays = + setOf( + RelayUrlNormalizer.normalize("wss://relay.damus.io/"), + RelayUrlNormalizer.normalize("wss://relay.damus2.io/"), + RelayUrlNormalizer.normalize("wss://relay.damus3.io/"), + ), + signer = loggedIn, + pollOption = null, + message = "test", + zapType = LnZapEvent.ZapType.PRIVATE, + toUserPubHex = null, + ) } - } - assertNotNull(resultPrivateZap) + val recepientPK = privateZapRequest.zappedAuthor().firstOrNull() + val recepientPost = privateZapRequest.zappedPost().firstOrNull() + + if (recepientPK != null && recepientPost != null) { + val privateKey = + createEncryptionPrivateKey( + loggedIn.keyPair.privKey!!.toHexKey(), + recepientPost, + privateZapRequest.createdAt, + ) + val decodedPrivateZap = PrivateZapRequestBuilder().decryptAnonTag(privateZapRequest.getAnonTag(), privateKey, recepientPK) + + assertNotNull(decodedPrivateZap) + } else { + fail("Should not be null") + } } } diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip59GiftWraps/GiftWrapEventTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip59GiftWraps/GiftWrapEventTest.kt index 62bcbf3bcc..1b244a2b30 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip59GiftWraps/GiftWrapEventTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip59GiftWraps/GiftWrapEventTest.kt @@ -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,6 +34,7 @@ import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.Hex +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull @@ -41,104 +42,87 @@ import org.junit.Assert.assertTrue import org.junit.Assert.fail import org.junit.Test import org.junit.runner.RunWith -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit @RunWith(AndroidJUnit4::class) class GiftWrapEventTest { @Test() - fun testNip17Utils() { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) - val message = "Hola, que tal?" + fun testNip17Utils() = + runBlocking { + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) + val message = "Hola, que tal?" - // Requires 3 tests - val countDownLatch = CountDownLatch(3) - - NIP17Factory().createMessageNIP17( - ChatMessageEvent.build( - message, - listOf(PTag(receiver.pubKey, null)), - ), - sender, - ) { events -> - countDownLatch.countDown() + val events = + NIP17Factory().createMessageNIP17( + ChatMessageEvent.build( + message, + listOf(PTag(receiver.pubKey, null)), + ), + sender, + ) // Simulate Receiver val eventsReceiverGets = events.wraps.filter { it.isTaggedUser(receiver.pubKey) } eventsReceiverGets.forEach { - it.cachedGift(receiver) { event -> - if (event is SealedRumorEvent) { - event.cachedRumor(receiver) { innerData -> - countDownLatch.countDown() - assertEquals(message, innerData.content) - } - } else { - fail("Wrong Event") - } + val event = it.unwrapThrowing(receiver) + if (event is SealedRumorEvent) { + val innerData = event.unsealThrowing(receiver) + assertEquals(message, innerData.content) + } else { + fail("Wrong Event") } } // Simulate Sender val eventsSenderGets = events.wraps.filter { it.isTaggedUser(sender.pubKey) } eventsSenderGets.forEach { - it.cachedGift(sender) { event -> - if (event is SealedRumorEvent) { - event.cachedRumor(sender) { innerData -> - countDownLatch.countDown() - assertEquals(message, innerData.content) - } - } else { - fail("Wrong Event") - } + val event = it.unwrapThrowing(sender) + if (event is SealedRumorEvent) { + val innerData = event.unsealThrowing(sender) + assertEquals(message, innerData.content) + } else { + fail("Wrong Event") } } } - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - } - @Test() - fun testNip17UtilsForGroups() { - val sender = NostrSignerInternal(KeyPair()) - val receiver1 = NostrSignerInternal(KeyPair()) - val receiver2 = NostrSignerInternal(KeyPair()) - val receiver3 = NostrSignerInternal(KeyPair()) - val receiver4 = NostrSignerInternal(KeyPair()) - val message = "Hola, que tal?" + fun testNip17UtilsForGroups() = + runBlocking { + val sender = NostrSignerInternal(KeyPair()) + val receiver1 = NostrSignerInternal(KeyPair()) + val receiver2 = NostrSignerInternal(KeyPair()) + val receiver3 = NostrSignerInternal(KeyPair()) + val receiver4 = NostrSignerInternal(KeyPair()) + val message = "Hola, que tal?" - val receivers = - listOf( - receiver1, - receiver2, - receiver3, - receiver4, - ) + val receivers = + listOf( + receiver1, + receiver2, + receiver3, + receiver4, + ) - val countDownLatch = CountDownLatch(receivers.size + 2) - - NIP17Factory().createMessageNIP17( - ChatMessageEvent.build( - message, - receivers.map { PTag(it.pubKey, null) }, - ), - sender, - ) { events -> - countDownLatch.countDown() + val events = + NIP17Factory().createMessageNIP17( + ChatMessageEvent.build( + message, + receivers.map { PTag(it.pubKey, null) }, + ), + sender, + ) // Simulate Receiver receivers.forEach { receiver -> val eventsReceiverGets = events.wraps.filter { it.isTaggedUser(receiver.pubKey) } eventsReceiverGets.forEach { - it.cachedGift(receiver) { event -> - if (event is SealedRumorEvent) { - event.cachedRumor(receiver) { innerData -> - countDownLatch.countDown() - assertEquals(message, innerData.content) - } - } else { - fail("Wrong Event") - } + val event = it.unwrapThrowing(receiver) + if (event is SealedRumorEvent) { + val innerData = event.unsealThrowing(receiver) + assertEquals(message, innerData.content) + } else { + fail("Wrong Event") } } } @@ -146,149 +130,126 @@ class GiftWrapEventTest { // Simulate Sender val eventsSenderGets = events.wraps.filter { it.isTaggedUser(sender.pubKey) } eventsSenderGets.forEach { - it.cachedGift(sender) { event -> - if (event is SealedRumorEvent) { - event.cachedRumor(sender) { innerData -> - countDownLatch.countDown() - assertEquals(message, innerData.content) - } - } else { - fail("Wrong Event") - } + val event = it.unwrapThrowing(sender) + if (event is SealedRumorEvent) { + val innerData = event.unsealThrowing(sender) + assertEquals(message, innerData.content) + } else { + fail("Wrong Event") } } } - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - } - @Test() - fun testInternalsSimpleMessage() { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) + fun testInternalsSimpleMessage() = + runBlocking { + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) - val countDownLatch = CountDownLatch(2) - - var giftWrapEventToSender: GiftWrapEvent? = null - var giftWrapEventToReceiver: GiftWrapEvent? = null - - sender.sign( - ChatMessageEvent.build( - msg = "Hi There!", - to = listOf(PTag(receiver.pubKey, null)), - ), - ) { senderMessage -> + val senderMessage = + sender.sign( + ChatMessageEvent.build( + msg = "Hi There!", + to = listOf(PTag(receiver.pubKey, null)), + ), + ) // MsgFor the Receiver + val encMsgFromSenderToReceiver = + SealedRumorEvent.create( + event = senderMessage, + encryptTo = receiver.pubKey, + signer = sender, + ) - SealedRumorEvent.create( - event = senderMessage, - encryptTo = receiver.pubKey, - signer = sender, - ) { encMsgFromSenderToReceiver -> - // Should expose sender - assertEquals(encMsgFromSenderToReceiver.pubKey, sender.pubKey) - // Should not expose receiver - assertTrue(encMsgFromSenderToReceiver.tags.isEmpty()) + // Should expose sender + assertEquals(encMsgFromSenderToReceiver.pubKey, sender.pubKey) + // Should not expose receiver + assertTrue(encMsgFromSenderToReceiver.tags.isEmpty()) + val giftWrapToReceiver = GiftWrapEvent.create( event = encMsgFromSenderToReceiver, recipientPubKey = receiver.pubKey, - ) { giftWrapToReceiver -> - // Should not be signed by neither sender nor receiver - assertNotEquals(giftWrapToReceiver.pubKey, sender.pubKey) - assertNotEquals(giftWrapToReceiver.pubKey, receiver.pubKey) + ) - // Should not include sender as recipient - assertNotEquals(giftWrapToReceiver.recipientPubKey(), sender.pubKey) + // Should not be signed by neither sender nor receiver + assertNotEquals(giftWrapToReceiver.pubKey, sender.pubKey) + assertNotEquals(giftWrapToReceiver.pubKey, receiver.pubKey) - // Should be addressed to the receiver - assertEquals(giftWrapToReceiver.recipientPubKey(), receiver.pubKey) + // Should not include sender as recipient + assertNotEquals(giftWrapToReceiver.recipientPubKey(), sender.pubKey) - giftWrapEventToReceiver = giftWrapToReceiver - - countDownLatch.countDown() - } - } + // Should be addressed to the receiver + assertEquals(giftWrapToReceiver.recipientPubKey(), receiver.pubKey) // MsgFor the Sender - SealedRumorEvent.create( - event = senderMessage, - encryptTo = sender.pubKey, - signer = sender, - ) { encMsgFromSenderToSender -> - // Should expose sender - assertEquals(encMsgFromSenderToSender.pubKey, sender.pubKey) - // Should not expose receiver - assertTrue(encMsgFromSenderToSender.tags.isEmpty()) + val encMsgFromSenderToSender = + SealedRumorEvent.create( + event = senderMessage, + encryptTo = sender.pubKey, + signer = sender, + ) + // Should expose sender + assertEquals(encMsgFromSenderToSender.pubKey, sender.pubKey) + // Should not expose receiver + assertTrue(encMsgFromSenderToSender.tags.isEmpty()) + + val giftWrapToSender = GiftWrapEvent.create( event = encMsgFromSenderToSender, recipientPubKey = sender.pubKey, - ) { giftWrapToSender -> - // Should not be signed by neither the sender, not the receiver - assertNotEquals(giftWrapToSender.pubKey, sender.pubKey) - assertNotEquals(giftWrapToSender.pubKey, receiver.pubKey) + ) - // Should not be addressed to the receiver - assertNotEquals(giftWrapToSender.recipientPubKey(), receiver.pubKey) - // Should be addressed to the sender - assertEquals(giftWrapToSender.recipientPubKey(), sender.pubKey) + // Should not be signed by neither the sender, not the receiver + assertNotEquals(giftWrapToSender.pubKey, sender.pubKey) + assertNotEquals(giftWrapToSender.pubKey, receiver.pubKey) - giftWrapEventToSender = giftWrapToSender + // Should not be addressed to the receiver + assertNotEquals(giftWrapToSender.recipientPubKey(), receiver.pubKey) + // Should be addressed to the sender + assertEquals(giftWrapToSender.recipientPubKey(), sender.pubKey) - countDownLatch.countDown() - } - } - } + // Done + // ----- + // Start receiving - // Done - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + // Receiver's side + // Makes sure it can only be decrypted by the target user - // Receiver's side - // Makes sure it can only be decrypted by the target user + assertNotNull(giftWrapToSender) + assertNotNull(giftWrapToReceiver) - assertNotNull(giftWrapEventToSender) - assertNotNull(giftWrapEventToReceiver) - - val countDownDecryptLatch = CountDownLatch(2) - - giftWrapEventToSender!!.cachedGift(sender) { unwrappedMsgForSenderBySender -> + val unwrappedMsgForSenderBySender = giftWrapToSender.unwrapThrowing(sender) assertEquals(SealedRumorEvent.KIND, unwrappedMsgForSenderBySender.kind) assertTrue(unwrappedMsgForSenderBySender is SealedRumorEvent) if (unwrappedMsgForSenderBySender is SealedRumorEvent) { - unwrappedMsgForSenderBySender.cachedRumor(sender) { unwrappedRumorToSenderBySender -> - assertEquals("Hi There!", unwrappedRumorToSenderBySender.content) - countDownDecryptLatch.countDown() - } + val unwrappedRumorToSenderBySender = unwrappedMsgForSenderBySender.unsealThrowing(sender) + assertEquals("Hi There!", unwrappedRumorToSenderBySender.content) - unwrappedMsgForSenderBySender.cachedRumor(receiver) { _ -> - fail( - "Should not be able to decrypt msg for the sender by the sender but decrypted with receiver", - ) + unwrappedMsgForSenderBySender.unsealOrNull(receiver)?.let { _ -> + fail("Should not be able to decrypt msg for the sender by the sender but decrypted with receiver") } } - } - giftWrapEventToReceiver!!.cachedGift(sender) { _ -> - fail("Should not be able to decrypt msg for the receiver decrypted by the sender") - } + giftWrapToReceiver.unwrapOrNull(sender)?.let { _ -> + fail("Should not be able to decrypt msg for the receiver decrypted by the sender") + } - giftWrapEventToSender!!.cachedGift(receiver) { _ -> - fail("Should not be able to decrypt msg for the sender decrypted by the receiver") - } + giftWrapToReceiver.unwrapOrNull(receiver)?.let { _ -> + fail("Should not be able to decrypt msg for the sender decrypted by the receiver") + } - giftWrapEventToReceiver!!.cachedGift(receiver) { unwrappedMsgForReceiverByReceiver -> + val unwrappedMsgForReceiverByReceiver = giftWrapToReceiver.unwrapThrowing(receiver) assertEquals(SealedRumorEvent.KIND, unwrappedMsgForReceiverByReceiver.kind) assertTrue(unwrappedMsgForReceiverByReceiver is SealedRumorEvent) if (unwrappedMsgForReceiverByReceiver is SealedRumorEvent) { - unwrappedMsgForReceiverByReceiver.cachedRumor(receiver) { unwrappedRumorToReceiverByReceiver -> - assertEquals("Hi There!", unwrappedRumorToReceiverByReceiver?.content) - countDownDecryptLatch.countDown() - } + val unwrappedRumorToReceiverByReceiver = unwrappedMsgForReceiverByReceiver.unsealThrowing(receiver) + assertEquals("Hi There!", unwrappedRumorToReceiverByReceiver.content) - unwrappedMsgForReceiverByReceiver.cachedRumor(sender) { unwrappedRumorToReceiverBySender -> + unwrappedMsgForReceiverByReceiver.unsealOrNull(sender)?.let { _ -> fail( "Should not be able to decrypt msg for the receiver by the receiver but decrypted with the sender", ) @@ -296,227 +257,194 @@ class GiftWrapEventTest { } } - assertTrue(countDownDecryptLatch.await(1, TimeUnit.SECONDS)) - } - @Test() - fun testInternalsGroupMessage() { - val sender = NostrSignerInternal(KeyPair()) - val receiverA = NostrSignerInternal(KeyPair()) - val receiverB = NostrSignerInternal(KeyPair()) + fun testInternalsGroupMessage() = + runBlocking { + val sender = NostrSignerInternal(KeyPair()) + val receiverA = NostrSignerInternal(KeyPair()) + val receiverB = NostrSignerInternal(KeyPair()) - val countDownLatch = CountDownLatch(3) + val senderMessage = + sender.sign( + ChatMessageEvent.build( + msg = "Who is going to the party tonight?", + to = listOf(PTag(receiverA.pubKey), PTag(receiverB.pubKey)), + ), + ) - var giftWrapEventToSender: GiftWrapEvent? = null - var giftWrapEventToReceiverA: GiftWrapEvent? = null - var giftWrapEventToReceiverB: GiftWrapEvent? = null + val msgFromSenderToReceiverA = + SealedRumorEvent.create( + event = senderMessage, + encryptTo = receiverA.pubKey, + signer = sender, + ) - sender.sign( - ChatMessageEvent.build( - msg = "Who is going to the party tonight?", - to = listOf(PTag(receiverA.pubKey), PTag(receiverB.pubKey)), - ), - ) { senderMessage -> - SealedRumorEvent.create( - event = senderMessage, - encryptTo = receiverA.pubKey, - signer = sender, - ) { msgFromSenderToReceiverA -> - // Should expose sender - assertEquals(msgFromSenderToReceiverA.pubKey, sender.pubKey) - // Should not expose receiver - assertTrue(msgFromSenderToReceiverA.tags.isEmpty()) + // Should expose sender + assertEquals(msgFromSenderToReceiverA.pubKey, sender.pubKey) + // Should not expose receiver + assertTrue(msgFromSenderToReceiverA.tags.isEmpty()) + val giftWrapForReceiverA = GiftWrapEvent.create( event = msgFromSenderToReceiverA, recipientPubKey = receiverA.pubKey, - ) { giftWrapForReceiverA -> - // Should not be signed by neither sender nor receiver - assertNotEquals(giftWrapForReceiverA.pubKey, sender.pubKey) - assertNotEquals(giftWrapForReceiverA.pubKey, receiverA.pubKey) - assertNotEquals(giftWrapForReceiverA.pubKey, receiverB.pubKey) + ) - // Should not include sender as recipient - assertNotEquals(giftWrapForReceiverA.recipientPubKey(), sender.pubKey) + // Should not be signed by neither sender nor receiver + assertNotEquals(giftWrapForReceiverA.pubKey, sender.pubKey) + assertNotEquals(giftWrapForReceiverA.pubKey, receiverA.pubKey) + assertNotEquals(giftWrapForReceiverA.pubKey, receiverB.pubKey) - // Should be addressed to the receiver - assertEquals(giftWrapForReceiverA.recipientPubKey(), receiverA.pubKey) + // Should not include sender as recipient + assertNotEquals(giftWrapForReceiverA.recipientPubKey(), sender.pubKey) - giftWrapEventToReceiverA = giftWrapForReceiverA + // Should be addressed to the receiver + assertEquals(giftWrapForReceiverA.recipientPubKey(), receiverA.pubKey) - countDownLatch.countDown() - } - } + val msgFromSenderToReceiverB = + SealedRumorEvent.create( + event = senderMessage, + encryptTo = receiverB.pubKey, + signer = sender, + ) - SealedRumorEvent.create( - event = senderMessage, - encryptTo = receiverB.pubKey, - signer = sender, - ) { msgFromSenderToReceiverB -> - // Should expose sender - assertEquals(msgFromSenderToReceiverB.pubKey, sender.pubKey) - // Should not expose receiver - assertTrue(msgFromSenderToReceiverB.tags.isEmpty()) + // Should expose sender + assertEquals(msgFromSenderToReceiverB.pubKey, sender.pubKey) + // Should not expose receiver + assertTrue(msgFromSenderToReceiverB.tags.isEmpty()) + val giftWrapForReceiverB = GiftWrapEvent.create( event = msgFromSenderToReceiverB, recipientPubKey = receiverB.pubKey, - ) { giftWrapForReceiverB -> - // Should not be signed by neither sender nor receiver - assertNotEquals(giftWrapForReceiverB.pubKey, sender.pubKey) - assertNotEquals(giftWrapForReceiverB.pubKey, receiverA.pubKey) - assertNotEquals(giftWrapForReceiverB.pubKey, receiverB.pubKey) + ) - // Should not include sender as recipient - assertNotEquals(giftWrapForReceiverB.recipientPubKey(), sender.pubKey) + // Should not be signed by neither sender nor receiver + assertNotEquals(giftWrapForReceiverB.pubKey, sender.pubKey) + assertNotEquals(giftWrapForReceiverB.pubKey, receiverA.pubKey) + assertNotEquals(giftWrapForReceiverB.pubKey, receiverB.pubKey) - // Should be addressed to the receiver - assertEquals(giftWrapForReceiverB.recipientPubKey(), receiverB.pubKey) + // Should not include sender as recipient + assertNotEquals(giftWrapForReceiverB.recipientPubKey(), sender.pubKey) - giftWrapEventToReceiverB = giftWrapForReceiverB + // Should be addressed to the receiver + assertEquals(giftWrapForReceiverB.recipientPubKey(), receiverB.pubKey) - countDownLatch.countDown() - } - } + val msgFromSenderToSender = + SealedRumorEvent.create( + event = senderMessage, + encryptTo = sender.pubKey, + signer = sender, + ) - SealedRumorEvent.create( - event = senderMessage, - encryptTo = sender.pubKey, - signer = sender, - ) { msgFromSenderToSender -> - // Should expose sender - assertEquals(msgFromSenderToSender.pubKey, sender.pubKey) - // Should not expose receiver - assertTrue(msgFromSenderToSender.tags.isEmpty()) + // Should expose sender + assertEquals(msgFromSenderToSender.pubKey, sender.pubKey) + // Should not expose receiver + assertTrue(msgFromSenderToSender.tags.isEmpty()) + val giftWrapToSender = GiftWrapEvent.create( event = msgFromSenderToSender, recipientPubKey = sender.pubKey, - ) { giftWrapToSender -> - // Should not be signed by neither the sender, not the receiver - assertNotEquals(giftWrapToSender.pubKey, sender.pubKey) - assertNotEquals(giftWrapToSender.pubKey, receiverA.pubKey) - assertNotEquals(giftWrapToSender.pubKey, receiverB.pubKey) + ) - // Should not be addressed to the receiver - assertNotEquals(giftWrapToSender.recipientPubKey(), receiverA.pubKey) - assertNotEquals(giftWrapToSender.recipientPubKey(), receiverB.pubKey) - // Should be addressed to the sender - assertEquals(giftWrapToSender.recipientPubKey(), sender.pubKey) + // Should not be signed by neither the sender, not the receiver + assertNotEquals(giftWrapToSender.pubKey, sender.pubKey) + assertNotEquals(giftWrapToSender.pubKey, receiverA.pubKey) + assertNotEquals(giftWrapToSender.pubKey, receiverB.pubKey) - giftWrapEventToSender = giftWrapToSender + // Should not be addressed to the receiver + assertNotEquals(giftWrapToSender.recipientPubKey(), receiverA.pubKey) + assertNotEquals(giftWrapToSender.recipientPubKey(), receiverB.pubKey) + // Should be addressed to the sender + assertEquals(giftWrapToSender.recipientPubKey(), sender.pubKey) - countDownLatch.countDown() - } - } - } + // Done + // ----- + // Decrypting - // Done - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + // Receiver's side + // Makes sure it can only be decrypted by the target user - // Receiver's side - // Makes sure it can only be decrypted by the target user + assertNotNull(giftWrapToSender) + assertNotNull(giftWrapForReceiverA) + assertNotNull(giftWrapForReceiverB) - assertNotNull(giftWrapEventToSender) - assertNotNull(giftWrapEventToReceiverA) - assertNotNull(giftWrapEventToReceiverB) - - val countDownDecryptLatch = CountDownLatch(3) - - giftWrapEventToSender?.cachedGift(sender) { unwrappedMsgForSenderBySender -> + val unwrappedMsgForSenderBySender = giftWrapToSender.unwrapThrowing(sender) assertEquals(SealedRumorEvent.KIND, unwrappedMsgForSenderBySender.kind) if (unwrappedMsgForSenderBySender is SealedRumorEvent) { - unwrappedMsgForSenderBySender.cachedRumor(receiverA) { unwrappedRumorToSenderByReceiverA -> + unwrappedMsgForSenderBySender.unsealOrNull(receiverA)?.let { _ -> fail() } - unwrappedMsgForSenderBySender.cachedRumor(receiverB) { unwrappedRumorToSenderByReceiverB -> + unwrappedMsgForSenderBySender.unsealOrNull(receiverB)?.let { _ -> fail() } - unwrappedMsgForSenderBySender.cachedRumor(sender) { unwrappedRumorToSenderBySender -> - assertEquals( - "Who is going to the party tonight?", - unwrappedRumorToSenderBySender.content, - ) - } + val unwrappedRumorToSenderBySender = unwrappedMsgForSenderBySender.unsealThrowing(sender) + assertEquals("Who is going to the party tonight?", unwrappedRumorToSenderBySender.content) } - countDownDecryptLatch.countDown() - } + giftWrapForReceiverA.unwrapOrNull(sender)?.let { unwrappedMsgForReceiverBySenderA -> + fail("Should not be able to decode msg to the receiver A with the sender's key") + } - giftWrapEventToReceiverA!!.cachedGift(sender) { unwrappedMsgForReceiverBySenderA -> - fail("Should not be able to decode msg to the receiver A with the sender's key") - } + giftWrapForReceiverB.unwrapOrNull(sender)?.let { unwrappedMsgForReceiverBySenderB -> + fail("Should not be able to decode msg to the receiver B with the sender's key") + } - giftWrapEventToReceiverB!!.cachedGift(sender) { unwrappedMsgForReceiverBySenderB -> - fail("Should not be able to decode msg to the receiver B with the sender's key") - } + giftWrapToSender.unwrapOrNull(receiverA)?.let { + fail("Should not be able to decode msg to sender with the receiver A's key") + } - giftWrapEventToSender!!.cachedGift(receiverA) { - fail("Should not be able to decode msg to sender with the receiver A's key") - } - - giftWrapEventToReceiverA!!.cachedGift(receiverA) { unwrappedMsgForReceiverAByReceiverA -> + val unwrappedMsgForReceiverAByReceiverA = giftWrapForReceiverA.unwrapThrowing(receiverA) assertEquals(SealedRumorEvent.KIND, unwrappedMsgForReceiverAByReceiverA.kind) if (unwrappedMsgForReceiverAByReceiverA is SealedRumorEvent) { - unwrappedMsgForReceiverAByReceiverA.cachedRumor(receiverA) { unwrappedRumorToReceiverAByReceiverA -> - assertEquals( - "Who is going to the party tonight?", - unwrappedRumorToReceiverAByReceiverA.content, - ) - } + val unwrappedRumorToReceiverAByReceiverA = unwrappedMsgForReceiverAByReceiverA.unsealThrowing(receiverA) + assertEquals("Who is going to the party tonight?", unwrappedRumorToReceiverAByReceiverA.content) - unwrappedMsgForReceiverAByReceiverA.cachedRumor(sender) { unwrappedRumorToReceiverABySender -> + unwrappedMsgForReceiverAByReceiverA.unsealOrNull(sender)?.let { unwrappedRumorToReceiverABySender -> fail() } - unwrappedMsgForReceiverAByReceiverA.cachedRumor(receiverB) { unwrappedRumorToReceiverAByReceiverB -> + unwrappedMsgForReceiverAByReceiverA.unsealOrNull(receiverB)?.let { unwrappedRumorToReceiverAByReceiverB -> fail() } } - countDownDecryptLatch.countDown() - } + giftWrapForReceiverB.unwrapOrNull(receiverA)?.let { + fail("Should not be able to decode msg to sender with the receiver A's key") + } - giftWrapEventToReceiverB!!.cachedGift(receiverA) { - fail("Should not be able to decode msg to sender with the receiver A's key") - } + giftWrapToSender.unwrapOrNull(receiverB)?.let { unwrappedMsgForSenderByReceiverB -> + fail("Should not be able to decode msg to sender with the receiver B's key") + } + giftWrapForReceiverA.unwrapOrNull(receiverB)?.let { unwrappedMsgForReceiverAByReceiverB -> + fail("Should not be able to decode msg to receiver A with the receiver B's key") + } - giftWrapEventToSender!!.cachedGift(receiverB) { unwrappedMsgForSenderByReceiverB -> - fail("Should not be able to decode msg to sender with the receiver B's key") - } - giftWrapEventToReceiverA!!.cachedGift(receiverB) { unwrappedMsgForReceiverAByReceiverB -> - fail("Should not be able to decode msg to receiver A with the receiver B's key") - } - giftWrapEventToReceiverB!!.cachedGift(receiverB) { unwrappedMsgForReceiverBByReceiverB -> + val unwrappedMsgForReceiverBByReceiverB = giftWrapForReceiverB.unwrapThrowing(receiverB) assertEquals(SealedRumorEvent.KIND, unwrappedMsgForReceiverBByReceiverB.kind) if (unwrappedMsgForReceiverBByReceiverB is SealedRumorEvent) { - unwrappedMsgForReceiverBByReceiverB.cachedRumor(receiverA) { unwrappedRumorToReceiverBByReceiverA -> + unwrappedMsgForReceiverBByReceiverB.unsealOrNull(receiverA)?.let { unwrappedRumorToReceiverBByReceiverA -> fail() } - unwrappedMsgForReceiverBByReceiverB.cachedRumor(receiverB) { unwrappedRumorToReceiverBByReceiverB -> - assertEquals( - "Who is going to the party tonight?", - unwrappedRumorToReceiverBByReceiverB.content, - ) + val unwrappedRumorToReceiverBByReceiverB = unwrappedMsgForReceiverBByReceiverB.unsealThrowing(receiverB) + assertEquals( + "Who is going to the party tonight?", + unwrappedRumorToReceiverBByReceiverB.content, + ) - countDownDecryptLatch.countDown() - } - - unwrappedMsgForReceiverBByReceiverB.cachedRumor(sender) { unwrappedRumorToReceiverBBySender -> + unwrappedMsgForReceiverBByReceiverB.unsealOrNull(sender)?.let { unwrappedRumorToReceiverBBySender -> fail() } } } - assertTrue(countDownDecryptLatch.await(1, TimeUnit.SECONDS)) - } - @Test fun testCaseFromAmethyst1() { val json = @@ -537,18 +465,14 @@ class GiftWrapEventTest { } """.trimIndent() - var rumor: Event? = null - - wait1SecondForResult { onDone -> - val privateKey = "de6152a85a0dea3b09a08a6f8139a314d498a7b52f7e5c28858b64270abd4c70" - unwrapUnsealRumor(json, privateKey) { - rumor = it - onDone() + val rumor: Event = + runBlocking { + val privateKey = "de6152a85a0dea3b09a08a6f8139a314d498a7b52f7e5c28858b64270abd4c70" + unwrapUnsealRumor(json, privateKey) } - } assertNotNull(rumor) - assertEquals("Hola, que tal?", rumor?.content) + assertEquals("Hola, que tal?", rumor.content) } @Test @@ -573,17 +497,13 @@ class GiftWrapEventTest { val privateKey = "409ff7654141eaa16cd2161fe5bd127aeaef71f270c67587474b78998a8e3533" - var rumor: Event? = null - - wait1SecondForResult { onDone -> - unwrapUnsealRumor(json, privateKey) { - rumor = it - onDone() + val rumor: Event = + runBlocking { + unwrapUnsealRumor(json, privateKey) } - } assertNotNull(rumor) - assertEquals("Hola, que tal?", rumor?.content) + assertEquals("Hola, que tal?", rumor.content) } @Test @@ -610,17 +530,14 @@ class GiftWrapEventTest { """.trimIndent() val privateKey = "09e0051fdf5fdd9dd7a54713583006442cbdbf87bdcdab1a402f26e527d56771" - var rumor: Event? = null - wait1SecondForResult { onDone -> - unwrapUnsealRumor(json, privateKey) { - rumor = it - onDone() + val rumor: Event = + runBlocking { + unwrapUnsealRumor(json, privateKey) } - } assertNotNull(rumor) - assertEquals("test", rumor?.content) + assertEquals("test", rumor.content) } @Test @@ -645,21 +562,17 @@ class GiftWrapEventTest { val privateKey = "09e0051fdf5fdd9dd7a54713583006442cbdbf87bdcdab1a402f26e527d56771" - var rumor: Event? = null - - wait1SecondForResult { onDone -> - unwrapUnsealRumor(json, privateKey) { - rumor = it - onDone() + val rumor = + runBlocking { + unwrapUnsealRumor(json, privateKey) } - } - assertEquals("asdfasdfasdf", rumor?.content) - assertEquals(1690659269L, rumor?.createdAt) - assertEquals("827ba09d32ab81d62c60f657b350198c8aaba84372dab9ad3f4f6b8b7274b707", rumor?.id) - assertEquals(14, rumor?.kind) - assertEquals("subject", rumor?.tags?.firstOrNull()?.get(0)) - assertEquals("test", rumor?.tags?.firstOrNull()?.get(1)) + assertEquals("asdfasdfasdf", rumor.content) + assertEquals(1690659269L, rumor.createdAt) + assertEquals("827ba09d32ab81d62c60f657b350198c8aaba84372dab9ad3f4f6b8b7274b707", rumor.id) + assertEquals(14, rumor.kind) + assertEquals("subject", rumor.tags.firstOrNull()?.get(0)) + assertEquals("test", rumor.tags.firstOrNull()?.get(1)) } @Test @@ -684,33 +597,28 @@ class GiftWrapEventTest { val privateKey = "7dd22cafc512c0bc363a259f6dcda515b13ae3351066d7976fd0bb79cbd0d700" - var rumor: Event? = null - - wait1SecondForResult { onDone -> - unwrapUnsealRumor(json, privateKey) { - rumor = it - onDone() + val rumor = + runBlocking { + unwrapUnsealRumor(json, privateKey) } - } - assertEquals("8d1a56008d4e31dae2fb8bef36b3efea519eff75f57033107e2aa16702466ef2", rumor?.id) - assertEquals("Howdy", rumor?.content) - assertEquals(1690833960L, rumor?.createdAt) - assertEquals(14, rumor?.kind) - assertEquals("p", rumor?.tags?.firstOrNull()?.get(0)) + assertEquals("8d1a56008d4e31dae2fb8bef36b3efea519eff75f57033107e2aa16702466ef2", rumor.id) + assertEquals("Howdy", rumor.content) + assertEquals(1690833960L, rumor.createdAt) + assertEquals(14, rumor.kind) + assertEquals("p", rumor.tags.firstOrNull()?.get(0)) assertEquals( "b08d8857a92b4d6aa580ff55cc3c18c4edf313c83388c34abc118621f74f1a78", - rumor?.tags?.firstOrNull()?.get(1), + rumor.tags.firstOrNull()?.get(1), ) - assertEquals("subject", rumor?.tags?.getOrNull(1)?.get(0)) - assertEquals("Stuff", rumor?.tags?.getOrNull(1)?.get(1)) + assertEquals("subject", rumor.tags.getOrNull(1)?.get(0)) + assertEquals("Stuff", rumor.tags.getOrNull(1)?.get(1)) } - fun unwrapUnsealRumor( + suspend fun unwrapUnsealRumor( json: String, privateKey: HexKey, - onReady: (Event) -> Unit, - ) { + ): Event { val pkBytes = NostrSignerInternal(KeyPair(privateKey.hexToByteArray())) val wrap = Event.fromJson(json) as GiftWrapEvent @@ -718,13 +626,13 @@ class GiftWrapEventTest { assertEquals(pkBytes.pubKey, wrap.recipientPubKey()) - wrap.cachedGift(pkBytes) { event -> - if (event is SealedRumorEvent) { - event.cachedRumor(pkBytes, onReady) - } else { - println(event.toJson()) - fail("Event is not a Sealed Rumor") - } + val event = wrap.unwrapThrowing(pkBytes) + return if (event is SealedRumorEvent) { + event.unsealThrowing(pkBytes) + } else { + println(event.toJson()) + fail("Event is not a Sealed Rumor") + throw Exception("Event is not a Sealed Rumor") } } @@ -750,23 +658,11 @@ class GiftWrapEventTest { val wrap = Event.fromJson(msg) as GiftWrapEvent wrap.checkSignature() - var event: Event? = null - - wait1SecondForResult { onDone -> - wrap.cachedGift(receiversPrivateKey) { - event = it - onDone() + val event = + runBlocking { + wrap.unwrapThrowing(receiversPrivateKey) } - } assertNotNull(event) } } - -fun wait1SecondForResult(run: (onDone: () -> Unit) -> Unit) { - val countDownLatch = CountDownLatch(1) - - run { countDownLatch.countDown() } - - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) -} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/utils/HexEncodingTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/utils/HexEncodingTest.kt index 42686132d0..2eab43448d 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/utils/HexEncodingTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/utils/HexEncodingTest.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/EventFactory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/EventFactory.kt index 19a1db44f8..4477dfe7d5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/EventFactory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/EventFactory.kt @@ -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,12 +20,11 @@ */ package com.vitorpamplona.quartz -import com.vitorpamplona.quartz.blossom.BlossomAuthorizationEvent -import com.vitorpamplona.quartz.blossom.BlossomServersEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent -import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent +import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent +import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent @@ -34,6 +33,7 @@ import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent +import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.relationshipStatus.RelationshipStatusEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.core.Event @@ -53,11 +53,11 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -import com.vitorpamplona.quartz.nip28PublicChat.ChannelListEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelHideMessageEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMuteUserEvent +import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent @@ -68,16 +68,26 @@ import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip37Drafts.DraftEvent +import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent -import com.vitorpamplona.quartz.nip51Lists.RelaySetEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +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.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarRSVPEvent @@ -94,13 +104,14 @@ import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityListEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent @@ -115,20 +126,35 @@ import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent + +interface EventBuilder { + fun build( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, + ): Event +} class EventFactory { companion object { - val factories: MutableMap>, String, HexKey) -> Event> = mutableMapOf() + val factories: MutableMap = mutableMapOf() - fun create( - id: String, - pubKey: String, + fun create( + id: HexKey, + pubKey: HexKey, createdAt: Long, kind: Int, tags: Array>, content: String, - sig: String, - ): Event = + sig: HexKey, + ): T = when (kind) { AdvertisedRelayListEvent.KIND -> AdvertisedRelayListEvent(id, pubKey, createdAt, tags, content, sig) AppDefinitionEvent.KIND -> AppDefinitionEvent(id, pubKey, createdAt, tags, content, sig) @@ -139,8 +165,10 @@ class EventFactory { BadgeAwardEvent.KIND -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig) BadgeDefinitionEvent.KIND -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig) BadgeProfilesEvent.KIND -> BadgeProfilesEvent(id, pubKey, createdAt, tags, content, sig) + BlockedRelayListEvent.KIND -> BlockedRelayListEvent(id, pubKey, createdAt, tags, content, sig) BlossomServersEvent.KIND -> BlossomServersEvent(id, pubKey, createdAt, tags, content, sig) BlossomAuthorizationEvent.KIND -> BlossomAuthorizationEvent(id, pubKey, createdAt, tags, content, sig) + BroadcastRelayListEvent.KIND -> BroadcastRelayListEvent(id, pubKey, createdAt, tags, content, sig) BookmarkListEvent.KIND -> BookmarkListEvent(id, pubKey, createdAt, tags, content, sig) CalendarDateSlotEvent.KIND -> CalendarDateSlotEvent(id, pubKey, createdAt, tags, content, sig) CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig) @@ -191,21 +219,27 @@ class EventFactory { DraftEvent.KIND -> DraftEvent(id, pubKey, createdAt, tags, content, sig) EmojiPackEvent.KIND -> EmojiPackEvent(id, pubKey, createdAt, tags, content, sig) EmojiPackSelectionEvent.KIND -> EmojiPackSelectionEvent(id, pubKey, createdAt, tags, content, sig) + EphemeralChatEvent.KIND -> EphemeralChatEvent(id, pubKey, createdAt, tags, content, sig) + EphemeralChatListEvent.KIND -> EphemeralChatListEvent(id, pubKey, createdAt, tags, content, sig) FileHeaderEvent.KIND -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig) ProfileGalleryEntryEvent.KIND -> ProfileGalleryEntryEvent(id, pubKey, createdAt, tags, content, sig) FileServersEvent.KIND -> FileServersEvent(id, pubKey, createdAt, tags, content, sig) FileStorageEvent.KIND -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig) FileStorageHeaderEvent.KIND -> FileStorageHeaderEvent(id, pubKey, createdAt, tags, content, sig) FhirResourceEvent.KIND -> FhirResourceEvent(id, pubKey, createdAt, tags, content, sig) + FollowListEvent.KIND -> FollowListEvent(id, pubKey, createdAt, tags, content, sig) GenericRepostEvent.KIND -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig) + GeohashListEvent.KIND -> GeohashListEvent(id, pubKey, createdAt, tags, content, sig) GiftWrapEvent.KIND -> GiftWrapEvent(id, pubKey, createdAt, tags, content, sig) GitIssueEvent.KIND -> GitIssueEvent(id, pubKey, createdAt, tags, content, sig) GitReplyEvent.KIND -> GitReplyEvent(id, pubKey, createdAt, tags, content, sig) GitPatchEvent.KIND -> GitPatchEvent(id, pubKey, createdAt, tags, content, sig) GitRepositoryEvent.KIND -> GitRepositoryEvent(id, pubKey, createdAt, tags, content, sig) GoalEvent.KIND -> GoalEvent(id, pubKey, createdAt, tags, content, sig) + HashtagListEvent.KIND -> HashtagListEvent(id, pubKey, createdAt, tags, content, sig) HighlightEvent.KIND -> HighlightEvent(id, pubKey, createdAt, tags, content, sig) HTTPAuthorizationEvent.KIND -> HTTPAuthorizationEvent(id, pubKey, createdAt, tags, content, sig) + IndexerRelayListEvent.KIND -> IndexerRelayListEvent(id, pubKey, createdAt, tags, content, sig) InteractiveStoryPrologueEvent.KIND -> InteractiveStoryPrologueEvent(id, pubKey, createdAt, tags, content, sig) InteractiveStorySceneEvent.KIND -> InteractiveStorySceneEvent(id, pubKey, createdAt, tags, content, sig) InteractiveStoryReadingStateEvent.KIND -> InteractiveStoryReadingStateEvent(id, pubKey, createdAt, tags, content, sig) @@ -220,9 +254,7 @@ class EventFactory { MetadataEvent.KIND -> MetadataEvent(id, pubKey, createdAt, tags, content, sig) MuteListEvent.KIND -> MuteListEvent(id, pubKey, createdAt, tags, content, sig) NNSEvent.KIND -> NNSEvent(id, pubKey, createdAt, tags, content, sig) - com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent.KIND -> - com.vitorpamplona.quartz.nip46RemoteSigner - .NostrConnectEvent(id, pubKey, createdAt, tags, content, sig) + NostrConnectEvent.KIND -> NostrConnectEvent(id, pubKey, createdAt, tags, content, sig) NIP90StatusEvent.KIND -> NIP90StatusEvent(id, pubKey, createdAt, tags, content, sig) NIP90ContentDiscoveryRequestEvent.KIND -> NIP90ContentDiscoveryRequestEvent(id, pubKey, createdAt, tags, content, sig) NIP90ContentDiscoveryResponseEvent.KIND -> NIP90ContentDiscoveryResponseEvent(id, pubKey, createdAt, tags, content, sig) @@ -235,12 +267,15 @@ class EventFactory { PollNoteEvent.KIND -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig) PrivateDmEvent.KIND -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig) PrivateOutboxRelayListEvent.KIND -> PrivateOutboxRelayListEvent(id, pubKey, createdAt, tags, content, sig) + ProxyRelayListEvent.KIND -> ProxyRelayListEvent(id, pubKey, createdAt, tags, content, sig) + PublicMessageEvent.KIND -> PublicMessageEvent(id, pubKey, createdAt, tags, content, sig) ReactionEvent.KIND -> ReactionEvent(id, pubKey, createdAt, tags, content, sig) RelationshipStatusEvent.KIND -> RelationshipStatusEvent(id, pubKey, createdAt, tags, content, sig) RelayAuthEvent.KIND -> RelayAuthEvent(id, pubKey, createdAt, tags, content, sig) RelaySetEvent.KIND -> RelaySetEvent(id, pubKey, createdAt, tags, content, sig) ReportEvent.KIND -> ReportEvent(id, pubKey, createdAt, tags, content, sig) RepostEvent.KIND -> RepostEvent(id, pubKey, createdAt, tags, content, sig) + RequestToVanishEvent.KIND -> RequestToVanishEvent(id, pubKey, createdAt, tags, content, sig) SealedRumorEvent.KIND -> SealedRumorEvent(id, pubKey, createdAt, tags, content, sig) SearchRelayListEvent.KIND -> SearchRelayListEvent(id, pubKey, createdAt, tags, content, sig) StatusEvent.KIND -> StatusEvent(id, pubKey, createdAt, tags, content, sig) @@ -248,16 +283,16 @@ class EventFactory { TextNoteModificationEvent.KIND -> TextNoteModificationEvent(id, pubKey, createdAt, tags, content, sig) TorrentEvent.KIND -> TorrentEvent(id, pubKey, createdAt, tags, content, sig) TorrentCommentEvent.KIND -> TorrentCommentEvent(id, pubKey, createdAt, tags, content, sig) + TrustedRelayListEvent.KIND -> TrustedRelayListEvent(id, pubKey, createdAt, tags, content, sig) VideoHorizontalEvent.KIND -> VideoHorizontalEvent(id, pubKey, createdAt, tags, content, sig) VideoVerticalEvent.KIND -> VideoVerticalEvent(id, pubKey, createdAt, tags, content, sig) + VoiceEvent.KIND -> VoiceEvent(id, pubKey, createdAt, tags, content, sig) + VoiceReplyEvent.KIND -> VoiceReplyEvent(id, pubKey, createdAt, tags, content, sig) WikiNoteEvent.KIND -> WikiNoteEvent(id, pubKey, createdAt, tags, content, sig) else -> { - factories[kind]?.let { - return it(id, pubKey, createdAt, tags, content, sig) - } - - Event(id, pubKey, createdAt, kind, tags, content, sig) + factories[kind]?.build(id, pubKey, createdAt, tags, content, sig) + ?: Event(id, pubKey, createdAt, kind, tags, content, sig) } - } + } as T } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/AudioHeaderEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/AudioHeaderEvent.kt index 396be396a1..6173346971 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/AudioHeaderEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/AudioHeaderEvent.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/TagArrayBuilderExt.kt index 78280af77a..5dbaaf522b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/tags/DownloadUrlTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/tags/DownloadUrlTag.kt index 6e69131b7f..501ce46f6d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/tags/DownloadUrlTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/tags/DownloadUrlTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/tags/StreamUrlTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/tags/StreamUrlTag.kt index 2e896aa98e..7cb18ba198 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/tags/StreamUrlTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/tags/StreamUrlTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/tags/WaveformTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/tags/WaveformTag.kt index 4916536322..7813f78b92 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/tags/WaveformTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/header/tags/WaveformTag.kt @@ -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,7 +22,7 @@ package com.vitorpamplona.quartz.experimental.audio.header.tags import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.quartz.nip01Core.core.has -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.utils.ensure class WaveformTag( @@ -38,12 +38,12 @@ class WaveformTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } - val wave = runCatching { EventMapper.mapper.readValue>(tag[1]) }.getOrNull() + val wave = runCatching { JsonMapper.mapper.readValue>(tag[1]) }.getOrNull() if (wave.isNullOrEmpty()) return null return WaveformTag(wave) } @JvmStatic - fun assemble(wave: List) = arrayOf(TAG_NAME, EventMapper.mapper.writeValueAsString(wave)) + fun assemble(wave: List) = arrayOf(TAG_NAME, JsonMapper.mapper.writeValueAsString(wave)) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/AudioTrackEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/AudioTrackEvent.kt index dfd0414018..3c9096d1cf 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/AudioTrackEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/AudioTrackEvent.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/TagArrayBuilderExt.kt index 027b02c9a7..9d4a90a313 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/CoverTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/CoverTag.kt index 56488bb958..8f260c5525 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/CoverTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/CoverTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/MediaTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/MediaTag.kt index 7ebaacc277..49bb66f49f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/MediaTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/MediaTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/ParticipantTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/ParticipantTag.kt index ebc074a715..3f7e65b260 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/ParticipantTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/ParticipantTag.kt @@ -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,6 +24,8 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -31,7 +33,7 @@ import com.vitorpamplona.quartz.utils.ensure @Immutable data class ParticipantTag( override val pubKey: String, - override val relayHint: String?, + override val relayHint: NormalizedRelayUrl?, ) : PubKeyReferenceTag { fun toTagArray() = assemble(pubKey, relayHint) @@ -43,7 +45,7 @@ data class ParticipantTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return ParticipantTag(tag[1], tag.getOrNull(2)) + return ParticipantTag(tag[1], tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) }) } @JvmStatic @@ -57,7 +59,7 @@ data class ParticipantTag( @JvmStatic fun assemble( pubkey: HexKey, - relayHint: String? = null, - ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint) + relayHint: NormalizedRelayUrl? = null, + ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint?.url) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/PriceTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/PriceTag.kt index dde2c19ae1..a331cd0652 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/PriceTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/PriceTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/TypeTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/TypeTag.kt index 34bf125a34..de62b0e615 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/TypeTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/TypeTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/bounties/BountyAddValueEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/bounties/BountyAddValueEvent.kt index 822da28530..d0ac4f63e6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/bounties/BountyAddValueEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/bounties/BountyAddValueEvent.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/bounties/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/bounties/EventExt.kt index 1b1e37669c..40b508a554 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/bounties/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/bounties/EventExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/bounties/RewardTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/bounties/RewardTag.kt index cb514b1d79..61842ebf45 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/bounties/RewardTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/bounties/RewardTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/bounties/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/bounties/TagArrayExt.kt index c659d42928..180a27298f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/bounties/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/bounties/TagArrayExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/decoupling/DecoupledCipher.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/decoupling/DecoupledCipher.kt index 8d7ca91f28..d41537de40 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/decoupling/DecoupledCipher.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/decoupling/DecoupledCipher.kt @@ -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 @@ -45,68 +45,62 @@ class DecoupledCipher { pubKey = fromPublicKey.hexToByteArray(), ) - fun encrypt( + suspend fun encrypt( decryptedContent: String, toPublicKey: HexKey, fromKeyList: EncryptionKeyListEvent, toKeyList: EncryptionKeyListEvent, signer: NostrSigner, - onReady: (String) -> Unit, - ) { + ): String? { val toKeys = toKeyList.keys() val sendToKey = if (toKeys.isEmpty()) toKeyList.pubKey else toKeys.random().pubkey val fromKeys = fromKeyList.keys() // uses the main key - if (fromKeys.isEmpty()) { - signer.nip44Encrypt(decryptedContent, sendToKey, onReady) + return if (fromKeys.isEmpty()) { + signer.nip44Encrypt(decryptedContent, sendToKey) } else { val keyToUse = fromKeys.random() - EncryptionKeyCache.getOrLoad( - deriveFromPubKey = signer.pubKey, - nonce = keyToUse.nonce, - load = { onLoaded -> - signer.deriveKey(keyToUse.nonce) { newPrivKey -> - onLoaded(newPrivKey.hexToByteArray()) - } - }, - ) { derivedPrivKey -> - onReady(innerEncrypt(decryptedContent, derivedPrivKey, sendToKey)) - } + EncryptionKeyCache + .getOrLoad( + deriveFromPubKey = signer.pubKey, + nonce = keyToUse.nonce, + load = { signer.deriveKey(keyToUse.nonce).hexToByteArray() }, + )?.let { derivedPrivKey -> + return innerEncrypt(decryptedContent, derivedPrivKey, sendToKey) + } } } - fun decrypt( + suspend fun decrypt( encryptedContent: String, fromPublicKey: HexKey, toPublicKey: HexKey, fromKeyList: EncryptionKeyListEvent, toEncryptedKeyList: EncryptionKeyListEvent, signer: NostrSigner, - onReady: (String) -> Unit, - ) { + ): String? { val fromKeys = fromKeyList.keys() val sentFromKey = if (fromKeys.isEmpty()) fromKeyList.pubKey else fromKeys.random().pubkey val keyToUse = toEncryptedKeyList.keys().firstOrNull { it.pubkey == toPublicKey } // uses the main key - if (signer.pubKey == toPublicKey) { - signer.nip44Decrypt(encryptedContent, sentFromKey, onReady) + return if (signer.pubKey == toPublicKey) { + signer.nip44Decrypt(encryptedContent, sentFromKey) } else if (keyToUse != null) { - EncryptionKeyCache.getOrLoad( - deriveFromPubKey = signer.pubKey, - nonce = keyToUse.nonce, - load = { onLoaded -> - signer.deriveKey(keyToUse.nonce) { newPrivKey -> - onLoaded(newPrivKey.hexToByteArray()) - } - }, - ) { derivedPrivKey -> - innerDecrypt(encryptedContent, derivedPrivKey, sentFromKey)?.let { onReady(it) } - } + EncryptionKeyCache + .getOrLoad( + deriveFromPubKey = signer.pubKey, + nonce = keyToUse.nonce, + load = { signer.deriveKey(keyToUse.nonce).hexToByteArray() }, + )?.let { derivedPrivKey -> + innerDecrypt(encryptedContent, derivedPrivKey, sentFromKey) + } + } else { + null } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/decoupling/EncryptionKeyCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/decoupling/EncryptionKeyCache.kt index 0cddc52af4..0a812043ff 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/decoupling/EncryptionKeyCache.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/decoupling/EncryptionKeyCache.kt @@ -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 @@ -35,29 +35,29 @@ object EncryptionKeyCache { deriveFromPubKey: HexKey, nonce: HexKey, privKey: ByteArray, - ) = sharedNonceKeyCache.put(idx(deriveFromPubKey, nonce), privKey) + ): ByteArray? = sharedNonceKeyCache.put(idx(deriveFromPubKey, nonce), privKey) fun get( deriveFromPubKey: HexKey, nonce: HexKey, - ) = sharedNonceKeyCache.get(idx(deriveFromPubKey, nonce)) + ): ByteArray? = sharedNonceKeyCache.get(idx(deriveFromPubKey, nonce)) inline fun getOrLoad( deriveFromPubKey: HexKey, nonce: HexKey, - load: (onLoaded: (privKey: ByteArray) -> Unit) -> Unit, - crossinline whenReady: (privKey: ByteArray) -> Unit, - ) { + load: () -> ByteArray?, + ): ByteArray? { val cachedPrivKey = get(deriveFromPubKey, nonce) if (cachedPrivKey != null) { - whenReady(cachedPrivKey) - return + return cachedPrivKey } - load { newPrivKey -> + val newPrivKey = load() + newPrivKey?.let { put(deriveFromPubKey, nonce, newPrivKey) - whenReady(newPrivKey) + newPrivKey } + return newPrivKey } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/decoupling/EncryptionKeyDerivation.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/decoupling/EncryptionKeyDerivation.kt index 15fbbad7a4..7b50b63bd4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/decoupling/EncryptionKeyDerivation.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/decoupling/EncryptionKeyDerivation.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/decoupling/setup/EncryptionKeyListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/decoupling/setup/EncryptionKeyListEvent.kt index e55232c0cd..e85dfc30d9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/decoupling/setup/EncryptionKeyListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/decoupling/setup/EncryptionKeyListEvent.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/decoupling/setup/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/decoupling/setup/TagArrayBuilderExt.kt index a2a761236d..cb0945026e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/decoupling/setup/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/decoupling/setup/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/decoupling/setup/tags/KeyTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/decoupling/setup/tags/KeyTag.kt index 4cc4a17077..3f5a8f11f3 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/decoupling/setup/tags/KeyTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/decoupling/setup/tags/KeyTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/edits/PrivateOutboxRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/edits/PrivateOutboxRelayListEvent.kt deleted file mode 100644 index 624e101e67..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/edits/PrivateOutboxRelayListEvent.kt +++ /dev/null @@ -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 com.vitorpamplona.quartz.experimental.edits - -import android.util.Log -import androidx.compose.runtime.Immutable -import com.fasterxml.jackson.module.kotlin.readValue -import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.TagArray -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address -import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri -import com.vitorpamplona.quartz.nip31Alts.AltTag -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.utils.bytesUsedInMemory -import com.vitorpamplona.quartz.utils.pointerSizeInBytes - -@Immutable -class PrivateOutboxRelayListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - @Transient private var privateTagsCache: Array>? = null - - override fun isContentEncoded() = true - - override fun countMemory(): Long = - super.countMemory() + - pointerSizeInBytes + (privateTagsCache?.sumOf { pointerSizeInBytes + it.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() } } ?: 0) - - fun relays(): List? = - tags - .mapNotNull { - if (it.size > 1 && it[0] == "relay") { - it[1] - } else { - null - } - }.plus( - privateTagsCache?.mapNotNull { - if (it.size > 1 && it[0] == "relay") { - it[1] - } else { - null - } - } ?: emptyList(), - ).ifEmpty { null } - - fun cachedPrivateTags(): Array>? = privateTagsCache - - fun privateTags( - signer: NostrSigner, - onReady: (Array>) -> Unit, - ) { - if (content.isEmpty()) { - onReady(emptyArray()) - return - } - - privateTagsCache?.let { - onReady(it) - return - } - - try { - signer.nip44Decrypt(content, pubKey) { - try { - privateTagsCache = EventMapper.mapper.readValue(it) - privateTagsCache?.let { onReady(it) } - } catch (e: Throwable) { - Log.w("PrivateOutboxRelayListEvent", "Error parsing the JSON: ${e.message}. Json `$it` from event `${toNostrUri()}`") - } - } - } catch (e: Throwable) { - Log.w("PrivateOutboxRelayListEvent", "Error decrypting content: ${e.message}. Event: `${toNostrUri()}`") - } - } - - companion object { - const val KIND = 10013 - val TAGS = arrayOf(AltTag.assemble("Relay list to store private content from this author")) - - fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG) - - fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, FIXED_D_TAG, null) - - fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG) - - fun encryptTags( - privateTags: Array>? = null, - signer: NostrSigner, - onReady: (String) -> Unit, - ) { - val msg = EventMapper.mapper.writeValueAsString(privateTags) - - signer.nip44Encrypt( - msg, - signer.pubKey, - onReady, - ) - } - - fun createTagArray(relays: List): Array> = - relays - .map { - arrayOf("relay", it) - }.toTypedArray() - - fun updateRelayList( - earlierVersion: PrivateOutboxRelayListEvent, - relays: List, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PrivateOutboxRelayListEvent) -> Unit, - ) { - val tags = - earlierVersion.privateTagsCache - ?.filter { it[0] != "relay" } - ?.plus( - relays.map { - arrayOf("relay", it) - }, - )?.toTypedArray() ?: emptyArray() - - encryptTags(tags, signer) { - signer.sign(createdAt, KIND, TAGS, it) { - it.privateTagsCache = tags - onReady(it) - } - } - } - - fun createFromScratch( - relays: List, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PrivateOutboxRelayListEvent) -> Unit, - ) { - create(relays, signer, createdAt, onReady) - } - - fun create( - relays: List, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PrivateOutboxRelayListEvent) -> Unit, - ) { - val privateTagArray = createTagArray(relays) - encryptTags(privateTagArray, signer) { privateTags -> - signer.sign(createdAt, KIND, TAGS, privateTags) { - it.privateTagsCache = privateTagArray - onReady(it) - } - } - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/edits/TextNoteModificationEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/edits/TextNoteModificationEvent.kt index f5f25ceff8..39e5a733d7 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/edits/TextNoteModificationEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/edits/TextNoteModificationEvent.kt @@ -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 @@ -45,15 +45,14 @@ class TextNoteModificationEvent( const val KIND = 1010 const val ALT = "Content Change Event" - fun create( + suspend fun create( content: String, eventId: HexKey, notify: HexKey?, summary: String?, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (TextNoteModificationEvent) -> Unit, - ) { + ): TextNoteModificationEvent { val tags = mutableListOf(arrayOf("e", eventId)) notify?.let { @@ -66,7 +65,7 @@ class TextNoteModificationEvent( tags.add(AltTag.assemble(ALT)) - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + return signer.sign(createdAt, KIND, tags.toTypedArray(), content) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/EphemeralChatEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/EphemeralChatEvent.kt new file mode 100644 index 0000000000..593a2b5562 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/EphemeralChatEvent.kt @@ -0,0 +1,70 @@ +/** + * 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.quartz.experimental.ephemChat.chat + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.ephemChat.chat.tags.RelayTag +import com.vitorpamplona.quartz.experimental.ephemChat.chat.tags.RoomTag +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class EphemeralChatEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun hasHomeRelay() = tags.any(RelayTag::match) + + fun room() = tags.firstNotNullOfOrNull(RoomTag::parse) ?: DEFAULT_ROOM + + fun relay() = tags.firstNotNullOfOrNull(RelayTag::parse) + + fun roomId() = relay()?.let { RoomId(room(), it) } + + companion object { + const val KIND = 23333 + const val ALT_DESCRIPTION = "Ephemeral Chat" + + const val DEFAULT_ROOM = "_" + + fun build( + message: String, + relay: NormalizedRelayUrl, + room: String = DEFAULT_ROOM, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, message, createdAt) { + room(room) + relay(relay) + alt(ALT_DESCRIPTION) + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/RoomId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/RoomId.kt new file mode 100644 index 0000000000..f4ca23a744 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/RoomId.kt @@ -0,0 +1,45 @@ +/** + * 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.quartz.experimental.ephemChat.chat + +import com.vitorpamplona.quartz.experimental.ephemChat.list.tags.RoomIdTag +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl + +data class RoomId( + val id: String, + val relayUrl: NormalizedRelayUrl, +) : Comparable { + fun toKey() = "$id@$relayUrl" + + fun toDisplayKey() = id + "@" + relayUrl.displayUrl() + + override fun compareTo(other: RoomId): Int { + val result = id.compareTo(other.id) + return if (result == 0) { + relayUrl.url.compareTo(other.relayUrl.url) + } else { + result + } + } + + fun toTagArray() = RoomIdTag.assemble(this) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..1b5978f30a --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/TagArrayBuilderExt.kt @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.ephemChat.chat + +import com.vitorpamplona.quartz.experimental.ephemChat.chat.tags.RelayTag +import com.vitorpamplona.quartz.experimental.ephemChat.chat.tags.RoomTag +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +fun TagArrayBuilder.room(room: String) = addUnique(RoomTag.assemble(room)) + +fun TagArrayBuilder.relay(relay: NormalizedRelayUrl) = addUnique(RelayTag.assemble(relay)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/tags/RelayTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/tags/RelayTag.kt new file mode 100644 index 0000000000..3399d98fc9 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/tags/RelayTag.kt @@ -0,0 +1,48 @@ +/** + * 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.quartz.experimental.ephemChat.chat.tags + +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.ensure + +class RelayTag { + companion object { + const val TAG_NAME = "relay" + + @JvmStatic + fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun parse(tag: Array): NormalizedRelayUrl? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + return RelayUrlNormalizer.normalizeOrNull(tag[1]) + } + + @JvmStatic + fun assemble(relay: NormalizedRelayUrl) = arrayOf(TAG_NAME, relay.url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/tags/RoomTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/tags/RoomTag.kt new file mode 100644 index 0000000000..e97ea19d96 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/tags/RoomTag.kt @@ -0,0 +1,45 @@ +/** + * 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.quartz.experimental.ephemChat.chat.tags + +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class RoomTag { + companion object { + const val TAG_NAME = "d" + + @JvmStatic + fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + @JvmStatic + fun assemble(room: String) = arrayOf(TAG_NAME, room) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/db/EphemeralRoom.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/db/EphemeralRoom.kt new file mode 100644 index 0000000000..5d687851d9 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/db/EphemeralRoom.kt @@ -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.quartz.experimental.ephemChat.db + +import androidx.compose.runtime.Stable +import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent +import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.LargeCache +import kotlinx.coroutines.flow.MutableStateFlow + +@Stable +class Room( + val roomId: RoomId, +) { + constructor(id: String, relayUrl: NormalizedRelayUrl) : this(RoomId(id, relayUrl)) + + val messages = LargeCache() + + fun addMsg(event: EphemeralChatEvent) { + if (!messages.containsKey(event.id)) { + messages.put(event.id, event) + messageFlow.tryEmit(RoomState(this)) + } + } + + fun removeMsg(event: EphemeralChatEvent) { + val existingEvent = messages.remove(event.id) + if (existingEvent != null) { + messageFlow.tryEmit(RoomState(this)) + } + } + + // Observers line up here. + val messageFlow = MutableStateFlow(RoomState(this)) +} + +class RoomState( + val room: Room, +) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/db/EphemeralRoomCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/db/EphemeralRoomCache.kt new file mode 100644 index 0000000000..2785203e06 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/db/EphemeralRoomCache.kt @@ -0,0 +1,46 @@ +/** + * 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.quartz.experimental.ephemChat.db + +import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.LargeCache + +class EphemeralRoomCache { + val rooms = LargeCache() + + fun getRoomIfExists( + roomId: String, + relayUrl: NormalizedRelayUrl, + ): Room? = rooms.get(RoomId(roomId, relayUrl)) + + fun getOrCreateRoom( + roomId: String, + relayUrl: NormalizedRelayUrl, + ): Room = rooms.getOrCreate(RoomId(roomId, relayUrl)) { Room(roomId, relayUrl) } + + fun findRoomsStartingWith(text: String): List { + if (text.isBlank()) return emptyList() + return rooms.filter { _, room -> + room.roomId.id.startsWith(text) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/EphemeralChatListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/EphemeralChatListEvent.kt new file mode 100644 index 0000000000..9a9de6d9d5 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/EphemeralChatListEvent.kt @@ -0,0 +1,180 @@ +/** + * 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.quartz.experimental.ephemChat.list + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId +import com.vitorpamplona.quartz.experimental.ephemChat.list.rooms +import com.vitorpamplona.quartz.experimental.ephemChat.list.tags.RoomIdTag +import com.vitorpamplona.quartz.experimental.ephemChat.list.tags.RoomIdTag.Companion.parse +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.removeParsing +import com.vitorpamplona.quartz.utils.TimeUtils +import java.lang.reflect.Modifier.isPrivate + +@Immutable +class EphemeralChatListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun publicRooms() = tags.rooms() + + fun publicRoomSet() = tags.roomSet() + + companion object { + const val KIND = 10023 + const val ALT = "Ephemeral Chat List" + const val FIXED_D_TAG = "" + + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) + + suspend fun create( + room: RoomId, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): EphemeralChatListEvent = + if (isPrivate) { + create( + publicRooms = emptyList(), + privateRooms = listOf(room), + signer = signer, + createdAt = createdAt, + ) + } else { + create( + publicRooms = listOf(room), + privateRooms = emptyList(), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun add( + earlierVersion: EphemeralChatListEvent, + room: RoomId, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): EphemeralChatListEvent = + if (isPrivate) { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + tags = earlierVersion.tags, + privateTags = privateTags.plus(room.toTagArray()), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.plus(room.toTagArray()), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun remove( + earlierVersion: EphemeralChatListEvent, + room: RoomId, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): EphemeralChatListEvent { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + return resign( + privateTags = privateTags.removeParsing(RoomIdTag::parse, room), + tags = earlierVersion.tags.removeParsing(RoomIdTag::parse, room), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun resign( + tags: TagArray, + privateTags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = resign( + content = PrivateTagsInContent.encryptNip04(privateTags, signer), + tags = tags, + signer = signer, + createdAt = createdAt, + ) + + suspend fun resign( + content: String, + tags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): EphemeralChatListEvent { + val newTags = + if (tags.fastAny(AltTag::match)) { + tags + } else { + tags + AltTag.assemble(ALT) + } + + return signer.sign(createdAt, KIND, newTags, content) + } + + suspend fun create( + publicRooms: List = emptyList(), + privateRooms: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): EphemeralChatListEvent { + val template = build(publicRooms, privateRooms, signer, createdAt) + return signer.sign(template) + } + + suspend fun build( + publicRooms: List = emptyList(), + privateRooms: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip04(privateRooms.map { it.toTagArray() }.toTypedArray(), signer), + createdAt = createdAt, + ) { + alt(ALT) + rooms(publicRooms) + + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..441cb8d6c7 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/TagArrayBuilderExt.kt @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.ephemChat.list + +import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId +import com.vitorpamplona.quartz.experimental.ephemChat.list.tags.RoomIdTag +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.roomId( + id: String, + relayUrl: String, +) = addUnique(RoomIdTag.assemble(id, relayUrl)) + +fun TagArrayBuilder.rooms(rooms: List) = addAll(rooms.map { it.toTagArray() }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/TagArrayExt.kt new file mode 100644 index 0000000000..b45a43d502 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/TagArrayExt.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.ephemChat.list + +import com.vitorpamplona.quartz.experimental.ephemChat.list.tags.RoomIdTag +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.rooms() = mapNotNull(RoomIdTag::parse) + +fun TagArray.roomSet() = mapNotNullTo(mutableSetOf(), RoomIdTag::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/tags/RoomIdTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/tags/RoomIdTag.kt new file mode 100644 index 0000000000..382fd64d9a --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/tags/RoomIdTag.kt @@ -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.quartz.experimental.ephemChat.list.tags + +import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.ensure + +class RoomIdTag { + companion object { + const val TAG_NAME = "group" + + @JvmStatic + fun parse(tag: Array): RoomId? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val relay = RelayUrlNormalizer.normalizeOrNull(tag[2]) ?: return null + return RoomId(tag[1], relay) + } + + @JvmStatic + fun assemble( + id: String, + relayUrl: String, + ) = arrayOf(TAG_NAME, id, relayUrl) + + @JvmStatic + fun assemble( + id: String, + relay: NormalizedRelayUrl, + ) = assemble(id, relay.url) + + @JvmStatic + fun assemble(id: RoomId) = arrayOf(TAG_NAME, id.id, id.relayUrl.url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/forks/BaseThreadedEventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/forks/BaseThreadedEventExt.kt index 6db5f7739a..42ac888f3e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/forks/BaseThreadedEventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/forks/BaseThreadedEventExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/forks/MarkedETagExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/forks/MarkedETagExt.kt index fa1a7b02ed..cc2971896f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/forks/MarkedETagExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/forks/MarkedETagExt.kt @@ -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,6 +20,7 @@ */ package com.vitorpamplona.quartz.experimental.forks +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag.MARKER @@ -29,8 +30,8 @@ fun MarkedETag.Companion.parseFork(tag: Array): MarkedETag? { // ["e", id hex, relay hint, marker, pubkey] return MarkedETag( tag[ORDER_EVT_ID], - tag[ORDER_RELAY], - tag[ORDER_MARKER], + tag[ORDER_RELAY].ifBlank { null }?.let { RelayUrlNormalizer.normalizeOrNull(it) }, + MARKER.FORK, tag.getOrNull( ORDER_PUBKEY, ), diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/inlineMetadata/Nip54InlineMetadata.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/inlineMetadata/Nip54InlineMetadata.kt index 9c3822ab78..9ea77f2a92 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/inlineMetadata/Nip54InlineMetadata.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/inlineMetadata/Nip54InlineMetadata.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryBaseEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryBaseEvent.kt index f1aa08c29c..c8fca73cd4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryBaseEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryBaseEvent.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryPrologueEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryPrologueEvent.kt index 489567c6ab..536c8bb2c0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryPrologueEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryPrologueEvent.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryReadingStateEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryReadingStateEvent.kt index 9ee1e3f0b9..1d5725f0fc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryReadingStateEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryReadingStateEvent.kt @@ -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,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.builder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag @@ -83,7 +84,7 @@ class InteractiveStoryReadingStateEvent( fun update( base: InteractiveStoryReadingStateEvent, currentScene: InteractiveStoryBaseEvent, - currentSceneRelay: String?, + currentSceneRelay: NormalizedRelayUrl?, createdAt: Long = TimeUtils.now(), ): EventTemplate { val rootTag = base.dTag() @@ -109,9 +110,9 @@ class InteractiveStoryReadingStateEvent( fun build( root: InteractiveStoryBaseEvent, - rootRelay: String?, + rootRelay: NormalizedRelayUrl?, currentScene: InteractiveStoryBaseEvent, - currentSceneRelay: String?, + currentSceneRelay: NormalizedRelayUrl?, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStorySceneEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStorySceneEvent.kt index 341da104aa..4826f08cf0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStorySceneEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStorySceneEvent.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/TagArrayBuilderExt.kt index 0028687866..b2f0c581a8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/tags/ReadStatusTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/tags/ReadStatusTag.kt index 28a5eaf8da..3a90c552e5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/tags/ReadStatusTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/tags/ReadStatusTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/tags/RootSceneTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/tags/RootSceneTag.kt index ae354c2d0b..ab50b9071f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/tags/RootSceneTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/tags/RootSceneTag.kt @@ -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,12 +23,13 @@ package com.vitorpamplona.quartz.experimental.interactiveStories.tags import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.ensure import com.vitorpamplona.quartz.utils.pointerSizeInBytes -import com.vitorpamplona.quartz.utils.removeTrailingNullsAndEmptyOthers @Immutable data class RootSceneTag( @@ -36,13 +37,13 @@ data class RootSceneTag( val pubKeyHex: String, val dTag: String, ) { - var relay: String? = null + var relay: NormalizedRelayUrl? = null constructor( kind: Int, pubKeyHex: HexKey, dTag: String, - relayHint: String?, + relayHint: NormalizedRelayUrl?, ) : this(kind, pubKeyHex, dTag) { this.relay = relayHint } @@ -52,11 +53,11 @@ data class RootSceneTag( 8L + // kind pubKeyHex.bytesUsedInMemory() + dTag.bytesUsedInMemory() + - (relay?.bytesUsedInMemory() ?: 0) + (relay?.url?.bytesUsedInMemory() ?: 0) fun toTag() = assembleATagId(kind, pubKeyHex, dTag) - fun toTagArray() = removeTrailingNullsAndEmptyOthers(TAG_NAME, toTag(), relay) + fun toTagArray() = assemble(kind, pubKeyHex, dTag, relay) companion object { const val TAG_NAME = "A" @@ -73,21 +74,22 @@ data class RootSceneTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } val address = Address.parse(tag[1]) ?: return null - return RootSceneTag(address.kind, address.pubKeyHex, address.dTag, tag.getOrNull(2)) + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + return RootSceneTag(address.kind, address.pubKeyHex, address.dTag, hint) } @JvmStatic fun assemble( aTagId: HexKey, - relay: String?, - ) = arrayOfNotNull(TAG_NAME, aTagId, relay) + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, aTagId, relay?.url) @JvmStatic fun assemble( kind: Int, pubKeyHex: String, dTag: String, - relay: String?, - ) = arrayOfNotNull(TAG_NAME, assembleATagId(kind, pubKeyHex, dTag), relay) + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, assembleATagId(kind, pubKeyHex, dTag), relay?.url) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/tags/StoryOptionTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/tags/StoryOptionTag.kt index a17697642f..7465ef4663 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/tags/StoryOptionTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/tags/StoryOptionTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/limits/LimitProcessor.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/limits/LimitProcessor.kt index f3c829d9f1..98a66bac76 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/limits/LimitProcessor.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/limits/LimitProcessor.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/limits/Limits.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/limits/Limits.kt index 348cd9454e..62d0b6da8a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/limits/Limits.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/limits/Limits.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/medical/FhirResourceEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/medical/FhirResourceEvent.kt index 0fbbfc0c4c..57577ee37e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/medical/FhirResourceEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/medical/FhirResourceEvent.kt @@ -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,7 +21,6 @@ package com.vitorpamplona.quartz.experimental.medical import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent.Companion.ALT import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder @@ -47,7 +46,7 @@ class FhirResourceEvent( createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, fhirPayload, createdAt) { - alt(ALT) + alt(ALT_DESCRIPTION) initializer() } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/data/FileStorageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/data/FileStorageEvent.kt index 00d51dc0d3..673808088a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/data/FileStorageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/data/FileStorageEvent.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/data/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/data/TagArrayBuilderExt.kt index d7f082826d..bdd4329a35 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/data/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/data/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/header/FileStorageHeaderEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/header/FileStorageHeaderEvent.kt index 0642578926..f026191fe4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/header/FileStorageHeaderEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/header/FileStorageHeaderEvent.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/header/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/header/TagArrayBuilderExt.kt index f4ec26cbee..099de07bb0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/header/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nip95/header/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/NNSEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/NNSEvent.kt index ee5886cbde..fb4d7a02ab 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/NNSEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/NNSEvent.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/TagArrayBuilderExt.kt index 6411d24bb3..2224747133 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/tags/IPv4Tag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/tags/IPv4Tag.kt index f6b2024f32..c60869fdf9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/tags/IPv4Tag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/tags/IPv4Tag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/tags/IPv6Tag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/tags/IPv6Tag.kt index db673d7db2..aecc10fa5a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/tags/IPv6Tag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/tags/IPv6Tag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/tags/VersionTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/tags/VersionTag.kt index 7de43e61d1..6618467a47 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/tags/VersionTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/nns/tags/VersionTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/profileGallery/GalleryListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/profileGallery/GalleryListEvent.kt index ab99c19779..a6af5d84e0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/profileGallery/GalleryListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/profileGallery/GalleryListEvent.kt @@ -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,9 +25,10 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip31Alts.AltTag -import com.vitorpamplona.quartz.nip51Lists.GeneralListEvent +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.utils.TimeUtils +@Deprecated("Replaced by NIP-68") @Immutable class GalleryListEvent( id: HexKey, @@ -36,108 +37,96 @@ class GalleryListEvent( tags: Array>, content: String, sig: HexKey, -) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { companion object { const val KIND = 10011 const val ALT = "Profile Gallery" - const val GALLERYTAGNAME = "url" + const val GALLERY_TAG_NAME = "url" - fun addEvent( + suspend fun addEvent( earlierVersion: GalleryListEvent?, eventId: HexKey, url: String, relay: String?, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (GalleryListEvent) -> Unit, - ) = addTag(earlierVersion, GALLERYTAGNAME, eventId, url, relay, signer, createdAt, onReady) + ) = addTag(earlierVersion, GALLERY_TAG_NAME, eventId, url, relay, signer, createdAt) - fun addTag( + suspend fun addTag( earlierVersion: GalleryListEvent?, tagName: String, - eventid: HexKey, + eventId: HexKey, url: String, relay: String?, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (GalleryListEvent) -> Unit, - ) { - val tags = arrayOf(tagName, url, eventid) + ): GalleryListEvent { + val tags = arrayOf(tagName, url, eventId) if (relay != null) { tags + relay } - add( + return add( earlierVersion, arrayOf(tags), signer, createdAt, - onReady, ) } - fun add( + suspend fun add( earlierVersion: GalleryListEvent?, listNewTags: Array>, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (GalleryListEvent) -> Unit, - ) { + ): GalleryListEvent = create( content = earlierVersion?.content ?: "", tags = listNewTags.plus(earlierVersion?.tags ?: arrayOf()), signer = signer, createdAt = createdAt, - onReady = onReady, ) - } - fun removeEvent( + suspend fun removeEvent( earlierVersion: GalleryListEvent, eventId: HexKey, url: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (GalleryListEvent) -> Unit, - ) = removeTag(earlierVersion, GALLERYTAGNAME, eventId, url, signer, createdAt, onReady) + ) = removeTag(earlierVersion, GALLERY_TAG_NAME, eventId, url, signer, createdAt) - fun removeReplaceable( + suspend fun removeReplaceable( earlierVersion: GalleryListEvent, aTag: ATag, url: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (GalleryListEvent) -> Unit, - ) = removeTag(earlierVersion, GALLERYTAGNAME, aTag.toTag(), url, signer, createdAt, onReady) + ) = removeTag(earlierVersion, GALLERY_TAG_NAME, aTag.toTag(), url, signer, createdAt) - private fun removeTag( + private suspend fun removeTag( earlierVersion: GalleryListEvent, tagName: String, - eventid: HexKey, + eventId: HexKey, url: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (GalleryListEvent) -> Unit, - ) { + ): GalleryListEvent = create( content = earlierVersion.content, tags = earlierVersion.tags - .filter { it.size <= 1 || !(it[0] == tagName && it[1] == url && it[2] == eventid) } + .filter { it.size <= 1 || !(it[0] == tagName && it[1] == url && it[2] == eventId) } .toTypedArray(), signer = signer, createdAt = createdAt, - onReady = onReady, ) - } - fun create( + suspend fun create( content: String, tags: Array>, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (GalleryListEvent) -> Unit, - ) { + ): GalleryListEvent { val newTags = if (tags.any { it.size > 1 && it[0] == "alt" }) { tags @@ -145,7 +134,7 @@ class GalleryListEvent( tags + AltTag.assemble(ALT) } - signer.sign(createdAt, KIND, newTags, content, onReady) + return signer.sign(createdAt, KIND, newTags, content) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/profileGallery/ProfileGalleryEntryEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/profileGallery/ProfileGalleryEntryEvent.kt index 124b4fd9b1..4058a01d75 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/profileGallery/ProfileGalleryEntryEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/profileGallery/ProfileGalleryEntryEvent.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/profileGallery/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/profileGallery/TagArrayBuilderExt.kt index afa46548b9..56bcfc9aeb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/profileGallery/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/profileGallery/TagArrayBuilderExt.kt @@ -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,6 +22,7 @@ package com.vitorpamplona.quartz.experimental.profileGallery import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag @@ -68,5 +69,5 @@ fun TagArrayBuilder.service(service: String) = add(Ser fun TagArrayBuilder.fromEvent( event: HexKey, - relayHint: String?, + relayHint: NormalizedRelayUrl?, ) = add(ETag.assemble(event, relayHint, null)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/publicMessages/PublicMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/publicMessages/PublicMessageEvent.kt new file mode 100644 index 0000000000..f688b42df5 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/publicMessages/PublicMessageEvent.kt @@ -0,0 +1,96 @@ +/** + * 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.quartz.experimental.publicMessages + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.publicMessages.tags.ReceiverTag +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.any +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent +import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints +import com.vitorpamplona.quartz.nip19Bech32.pubKeys +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class PublicMessageEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig), + PubKeyHintProvider, + SearchableEvent { + override fun indexableContent() = content + + override fun pubKeyHints() = tags.mapNotNull(ReceiverTag::parseAsHint) + citedNIP19().pubKeyHints() + + override fun linkedPubKeys() = tags.mapNotNull(ReceiverTag::parseKey) + citedNIP19().pubKeys() + + fun isIncluded(pubKey: HexKey) = tags.any(ReceiverTag::match, pubKey) || this.pubKey == pubKey + + fun group() = tags.mapNotNullTo(mutableListOf(ReceiverTag(this.pubKey, null)), ReceiverTag::parse) + + fun groupKeys() = tags.mapNotNullTo(mutableListOf(this.pubKey), ReceiverTag::parseKey) + + fun groupKeySet() = tags.mapNotNullTo(mutableSetOf(this.pubKey), ReceiverTag::parseKey) + + companion object { + const val KIND = 24 + const val ALT_DESCRIPTION = "Public Message" + + fun build( + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + initializer() + } + + fun build( + to: ReceiverTag, + msg: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, msg, createdAt) { + alt(ALT_DESCRIPTION) + toUser(to) + initializer() + } + + fun build( + to: List, + msg: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, msg, createdAt) { + alt(ALT_DESCRIPTION) + toGroup(to) + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/publicMessages/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/publicMessages/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..9370494dda --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/publicMessages/TagArrayBuilderExt.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.publicMessages + +import com.vitorpamplona.quartz.experimental.publicMessages.tags.ReceiverTag +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.toGroup(list: List) = addAll(list.map { it.toTagArray() }) + +fun TagArrayBuilder.toUser(user: ReceiverTag) = add(user.toTagArray()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/publicMessages/tags/ReceiverTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/publicMessages/tags/ReceiverTag.kt new file mode 100644 index 0000000000..7378e2086e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/publicMessages/tags/ReceiverTag.kt @@ -0,0 +1,95 @@ +/** + * 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.quartz.experimental.publicMessages.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +class ReceiverTag( + val pubKey: HexKey, + val relayHint: NormalizedRelayUrl? = null, +) { + fun toNProfile(): String = NProfile.create(pubKey, relayHint?.let { listOf(it) } ?: emptyList()) + + fun toNPub(): String = pubKey.hexToByteArray().toNpub() + + fun toTagArray() = assemble(pubKey, relayHint) + + fun toTagIdOnly() = assemble(pubKey, null) + + companion object Companion { + const val TAG_NAME = "p" + + fun isTagged(tag: Array): Boolean = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64 + + fun match( + tag: Array, + key: HexKey, + ): Boolean = tag.has(1) && tag[0] == TAG_NAME && tag[1] == key + + @JvmStatic + fun parse(tag: Tag): ReceiverTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return ReceiverTag(tag[1], hint) + } + + @JvmStatic + fun parseKey(tag: Tag): HexKey? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + return tag[1] + } + + @JvmStatic + fun parseAsHint(tag: Tag): PubKeyHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val hint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + + ensure(hint != null) { return null } + + return PubKeyHint(tag[1], hint) + } + + @JvmStatic + fun assemble( + pubkey: HexKey, + relayHint: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint?.url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/RelationshipStatusEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/RelationshipStatusEvent.kt index 23792c2cbd..ac22a1e95c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/RelationshipStatusEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/RelationshipStatusEvent.kt @@ -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,7 +21,7 @@ package com.vitorpamplona.quartz.experimental.relationshipStatus import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.PetnameTag +import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.PetNameTag import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.RankTag import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.SummaryTag import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -45,7 +45,7 @@ class RelationshipStatusEvent( ) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { fun rank() = tags.firstNotNullOfOrNull(RankTag::parse) - fun petname() = tags.firstNotNullOfOrNull(PetnameTag::parse) + fun petName() = tags.firstNotNullOfOrNull(PetNameTag::parse) fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse) @@ -53,16 +53,15 @@ class RelationshipStatusEvent( const val KIND = 30382 const val ALT = "Relationship Status" - fun create( + suspend fun create( targetUser: HexKey, - petname: String? = null, + petName: String? = null, summary: String? = null, signer: NostrSigner, createdAt: Long = TimeUtils.now(), publicInitializer: TagArrayBuilder.() -> Unit = {}, privateInitializer: TagArrayBuilder.() -> Unit = {}, - onReady: (RelationshipStatusEvent) -> Unit, - ) { + ): RelationshipStatusEvent { val publicTags = tagArray { alt(ALT) @@ -72,14 +71,13 @@ class RelationshipStatusEvent( val privateTags = tagArray { - petname?.let { petname(it) } + petName?.let { petName(it) } summary?.let { summary(it) } privateInitializer() } - PrivateTagsInContent.encryptNip44(privateTags, signer) { content -> - signer.sign(createdAt, KIND, publicTags, content, onReady) - } + val encryptedContent = PrivateTagsInContent.encryptNip44(privateTags, signer) + return signer.sign(createdAt, KIND, publicTags, encryptedContent) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/TagArrayBuilderExt.kt index 3d371ef77d..58626823ad 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/TagArrayBuilderExt.kt @@ -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,13 +20,13 @@ */ package com.vitorpamplona.quartz.experimental.relationshipStatus -import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.PetnameTag +import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.PetNameTag import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.RankTag import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.SummaryTag import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder fun TagArrayBuilder.rank(rank: Int) = add(RankTag.assemble(rank)) -fun TagArrayBuilder.petname(name: String) = add(PetnameTag.assemble(name)) +fun TagArrayBuilder.petName(name: String) = add(PetNameTag.assemble(name)) fun TagArrayBuilder.summary(summary: String) = add(SummaryTag.assemble(summary)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/tags/PetnameTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/tags/PetNameTag.kt similarity index 93% rename from quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/tags/PetnameTag.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/tags/PetNameTag.kt index ea9b4981a1..32561ccd62 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/tags/PetnameTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/tags/PetNameTag.kt @@ -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.quartz.experimental.relationshipStatus.tags import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.utils.ensure -class PetnameTag { +class PetNameTag { companion object { const val TAG_NAME = "petname" @@ -36,6 +36,6 @@ class PetnameTag { } @JvmStatic - fun assemble(petname: String) = arrayOf(TAG_NAME, petname) + fun assemble(petName: String) = arrayOf(TAG_NAME, petName) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/tags/RankTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/tags/RankTag.kt index b39bce3ae5..d543c46584 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/tags/RankTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/tags/RankTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/tags/SummaryTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/tags/SummaryTag.kt index 058c58a74b..7a78014e69 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/tags/SummaryTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/relationshipStatus/tags/SummaryTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/PollNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/PollNoteEvent.kt index d0d2279308..3c9d165854 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/PollNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/PollNoteEvent.kt @@ -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 @@ -28,9 +28,26 @@ import com.vitorpamplona.quartz.experimental.zapPolls.tags.MinimumTag import com.vitorpamplona.quartz.experimental.zapPolls.tags.PollOptionTag import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip19Bech32.addressHints +import com.vitorpamplona.quartz.nip19Bech32.addressIds +import com.vitorpamplona.quartz.nip19Bech32.eventHints +import com.vitorpamplona.quartz.nip19Bech32.eventIds +import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints +import com.vitorpamplona.quartz.nip19Bech32.pubKeys import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -41,7 +58,59 @@ class PollNoteEvent( tags: Array>, content: String, sig: HexKey, -) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider, + SearchableEvent { + override fun indexableContent() = content + pollOptionsArray().map { "\nOption: " + it.descriptor } + + override fun eventHints(): List { + val eHints = tags.mapNotNull(MarkedETag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseEventAsHint) + val nip19Hints = citedNIP19().eventHints() + + return eHints + qHints + nip19Hints + } + + override fun linkedEventIds(): List { + val eHints = tags.mapNotNull(MarkedETag::parseId) + val qHints = tags.mapNotNull(QTag::parseEventId) + val nip19Hints = citedNIP19().eventIds() + + return eHints + qHints + nip19Hints + } + + override fun addressHints(): List { + val aHints = tags.mapNotNull(ATag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseAddressAsHint) + val nip19Hints = citedNIP19().addressHints() + + return aHints + qHints + nip19Hints + } + + override fun linkedAddressIds(): List { + val aHints = tags.mapNotNull(ATag::parseAddressId) + val qHints = tags.mapNotNull(QTag::parseAddressId) + val nip19Hints = citedNIP19().addressIds() + + return aHints + qHints + nip19Hints + } + + override fun pubKeyHints(): List { + val pHints = tags.mapNotNull(PTag::parseAsHint) + val nip19Hints = citedNIP19().pubKeyHints() + + return pHints + nip19Hints + } + + override fun linkedPubKeys(): List { + val pHints = tags.mapNotNull(PTag::parseKey) + val nip19Hints = citedNIP19().pubKeys() + + return pHints + nip19Hints + } + fun pollOptionsArray() = tags.mapNotNull(PollOptionTag::parse) fun pollOptions() = pollOptionsArray().associate { it.index to it.descriptor } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/TagArrayBuilderExt.kt index 37b784d2af..b5b36d3c01 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/ClosedAtTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/ClosedAtTag.kt index 21883f0521..f788d32123 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/ClosedAtTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/ClosedAtTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/ConsensusThresholdTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/ConsensusThresholdTag.kt index b1d2fce008..175bc05fd6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/ConsensusThresholdTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/ConsensusThresholdTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/MaximumTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/MaximumTag.kt index 286c9876d1..94eea5fa79 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/MaximumTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/MaximumTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/MinimumTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/MinimumTag.kt index 5a1fb5ed83..78541c35b0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/MinimumTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/MinimumTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/PollOptionTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/PollOptionTag.kt index 1ab922f5fe..f22468c2da 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/PollOptionTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/zapPolls/tags/PollOptionTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/lightning/LnInvoiceUtil.kt b/quartz/src/main/java/com/vitorpamplona/quartz/lightning/LnInvoiceUtil.kt index 5b0cfd9a50..d800d8eb80 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/lightning/LnInvoiceUtil.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/lightning/LnInvoiceUtil.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/lightning/LnWithdrawalUtil.kt b/quartz/src/main/java/com/vitorpamplona/quartz/lightning/LnWithdrawalUtil.kt index 26babd9af4..25d192fe1d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/lightning/LnWithdrawalUtil.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/lightning/LnWithdrawalUtil.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/lightning/Lud06.kt b/quartz/src/main/java/com/vitorpamplona/quartz/lightning/Lud06.kt index 340f8f2ed8..4798fb5bc9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/lightning/Lud06.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/lightning/Lud06.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/EventExt.kt index 052f2b9ba2..81e828a657 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/EventExt.kt @@ -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 @@ -41,17 +41,10 @@ fun Event.verifySignature(): Boolean { /** Checks if the ID is correct and then if the pubKey's secret key signed the event. */ fun Event.checkSignature() { if (!verifyId()) { - throw Exception( - """ - |Unexpected ID. - | Event: ${toJson()} - | Actual ID: $id - | Generated: ${generateId()} - """.trimIndent(), - ) + throw Exception("ID mismatch: our ID is ${generateId()} for event ${toJson()}") } if (!verifySignature()) { - throw Exception("""Bad signature!""") + throw Exception("Bad signature!") } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/AddressableEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/AddressableEvent.kt index d57e5280ec..c59f5b0192 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/AddressableEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/AddressableEvent.kt @@ -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,6 +21,7 @@ package com.vitorpamplona.quartz.nip01Core.core import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address @@ -28,7 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address interface AddressableEvent : IEvent { fun dTag(): String - fun aTag(relayHint: String? = null): ATag + fun aTag(relayHint: NormalizedRelayUrl? = null): ATag fun address(): Address diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/BaseAddressableEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/BaseAddressableEvent.kt index 4cac7071f1..cf857f66c8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/BaseAddressableEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/BaseAddressableEvent.kt @@ -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,6 +21,7 @@ package com.vitorpamplona.quartz.nip01Core.core import androidx.compose.runtime.Immutable +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.dTags.dTag @@ -38,7 +39,7 @@ open class BaseAddressableEvent( AddressableEvent { override fun dTag() = tags.dTag() - override fun aTag(relayHint: String?) = ATag(kind, pubKey, dTag(), relayHint) + override fun aTag(relayHint: NormalizedRelayUrl?) = ATag(kind, pubKey, dTag(), relayHint) override fun address() = Address(kind, pubKey, dTag()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/BaseReplaceableEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/BaseReplaceableEvent.kt index 24404b5a2e..8f520077bc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/BaseReplaceableEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/BaseReplaceableEvent.kt @@ -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,6 +21,7 @@ package com.vitorpamplona.quartz.nip01Core.core import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address @@ -36,7 +37,7 @@ open class BaseReplaceableEvent( ) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { override fun dTag() = FIXED_D_TAG - override fun aTag(relayHint: String?) = ATag(kind, pubKey, FIXED_D_TAG, relayHint) + override fun aTag(relayHint: NormalizedRelayUrl?) = ATag(kind, pubKey, FIXED_D_TAG, relayHint) override fun address() = Address(kind, pubKey, dTag()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/Event.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/Event.kt index 7c536a4e6f..09ea417cfe 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/Event.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/Event.kt @@ -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,10 +20,10 @@ */ package com.vitorpamplona.quartz.nip01Core.core +import android.util.Log import androidx.compose.runtime.Immutable -import com.fasterxml.jackson.annotation.JsonProperty import com.vitorpamplona.quartz.nip01Core.jackson.EventManualSerializer -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.bytesUsedInMemory @@ -32,15 +32,21 @@ import com.vitorpamplona.quartz.utils.pointerSizeInBytes @Immutable open class Event( val id: HexKey, - @JsonProperty("pubkey") val pubKey: HexKey, - @JsonProperty("created_at") val createdAt: Long, - val kind: Int, + val pubKey: HexKey, + val createdAt: Long, + val kind: Kind, val tags: TagArray, val content: String, val sig: HexKey, ) : IEvent { + /** + * Set this to true if the .content is encrypted or encoded in a + * way that it should not be indexed for local search. + */ open fun isContentEncoded() = false + open fun extraIndexableTagNames() = emptySet() + open fun countMemory(): Long = 7 * pointerSizeInBytes + // 7 fields, 4 bytes each reference (32bit) 12L + // createdAt + kind @@ -52,13 +58,16 @@ open class Event( fun toJson(): String = EventManualSerializer.toJson(id, pubKey, createdAt, kind, tags, content, sig) - /** - * For debug purposes only - */ - fun toPrettyJson(): String = EventManualSerializer.toPrettyJson(id, pubKey, createdAt, kind, tags, content, sig) - companion object { - fun fromJson(json: String): Event = EventMapper.fromJson(json) + fun fromJson(json: String): Event = JsonMapper.fromJson(json) + + fun fromJsonOrNull(json: String) = + try { + fromJson(json) + } catch (e: Exception) { + Log.w("Event", "Unable to parse event JSON: $json", e) + null + } fun build( kind: Int, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt index 1330757784..eae01d7264 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/IEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/IEvent.kt index 66d87fc14c..aba2ee519b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/IEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/IEvent.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/Kind.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/Kind.kt new file mode 100644 index 0000000000..0aea6ba6f0 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/Kind.kt @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.core + +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent + +typealias Kind = Int + +fun Kind.isEphemeral() = this >= 20_000 && this < 30_000 + +fun Kind.isReplaceable() = this == MetadataEvent.KIND || this == ContactListEvent.KIND || (this >= 10_000 && this < 20_000) + +fun Kind.isAddressable() = this >= 30_000 && this < 40_000 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/Tag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/Tag.kt index d9273988a8..e1692b341e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/Tag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/Tag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/TagArray.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/TagArray.kt index 36ba47ae64..f8b8a9926f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/TagArray.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/TagArray.kt @@ -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,13 +24,52 @@ typealias TagArray = Array> fun TagArray.builder(initializer: TagArrayBuilder.() -> Unit = {}) = TagArrayBuilder().addAll(this).apply(initializer).build() +inline fun Array.fastForEach(action: (T) -> Unit) { + for (index in indices) action(get(index)) +} + +inline fun Array.fastAny(predicate: (T) -> Boolean): Boolean { + for (index in indices) if (predicate(get(index))) return true + return false +} + +inline fun Array.fastFirstOrNull(predicate: (T) -> Boolean): T? { + for (index in indices) { + if (predicate(get(index))) return get(index) + } + return null +} + +inline fun Array.fastFirstNotNullOfOrNull(transform: (T) -> R?): R? { + for (index in indices) { + val result = transform(get(index)) + if (result != null) { + return result + } + } + return null +} + +inline fun Array.fastFirstNotNullOfOrNull( + transform: (T, U) -> R?, + extras: U, +): R? { + for (index in indices) { + val result = transform(get(index), extras) + if (result != null) { + return result + } + } + return null +} + /** * Performs the given [action] on each tag that matches the given [tagName]. */ fun TagArray.forEachTagged( tagName: String, action: (eventId: HexKey) -> Unit, -) = this.forEach { +) = this.fastForEach { if (it.size > 1 && it[0] == tagName) { action(it[1]) } @@ -42,12 +81,7 @@ fun TagArray.forEachTagged( fun TagArray.anyTagged( tagName: String, predicate: (tagValue: String) -> Boolean, -) = this.any { it.size > 1 && it[0] == tagName && predicate(it[1]) } - -/** - * Returns `true` if at least one tag matches the given [tagName]. - */ -fun TagArray.anyTagged(tagName: String) = this.any { it.size > 0 && it[0] == tagName } +) = this.fastAny { it.size > 1 && it[0] == tagName && predicate(it[1]) } /** * Returns `true` if at least one tag matches the given [tagName] and its value starts with a prefix @@ -56,12 +90,12 @@ fun TagArray.anyTagWithValueStartingWith( tagName: String, valuePrefix: String, ignoreCase: Boolean = true, -): Boolean = this.any { it.size > 1 && it[0] == tagName && it[1].startsWith(valuePrefix, ignoreCase) } +): Boolean = this.fastAny { it.size > 1 && it[0] == tagName && it[1].startsWith(valuePrefix, ignoreCase) } /** * Returns `true` if at least one tag matches the given name and has a value */ -fun TagArray.hasTagWithContent(tagName: String) = this.any { it.size > 1 && it[0] == tagName } +fun TagArray.hasTagWithContent(tagName: String) = this.fastAny { it.size > 1 && it[0] == tagName } /** * Returns a list containing only the non-null results of applying the tag value to the given [transform] function @@ -78,82 +112,20 @@ fun TagArray.mapValueTagged( } } -/** - * Returns a list containing only the non-null results of applying the tag array to the given [transform] function - * to each tag that matches the [tagName] - */ -fun TagArray.mapTagged( - tagName: String, - map: (tagValue: Array) -> R, -) = this.mapNotNull { - if (it.size > 1 && it[0] == tagName) { - map(it) - } else { - null - } -} - -/** - * Returns a list containing only the non-null tag values that match the [tagName] - */ -fun TagArray.mapValues(tagName: String) = - this.mapNotNull { - if (it.size > 1 && it[0] == tagName) { - it[1] - } else { - null - } - } - -/** - * Returns the first non-null value produced by [transform] function being applied to all tags - * that match the [tagName] - */ -fun TagArray.firstMappedTag( - tagName: String, - transform: (tagValue: Array) -> R, -) = this.firstNotNullOfOrNull { - if (it.size > 1 && it[0] == tagName) { - transform(it) - } else { - null - } -} - -/** - * Returns a list of tags that match the given [tagName]. - */ -fun TagArray.filterByTag(tagName: String) = this.filter { it.size > 0 && it[0] == tagName } - -/** - * Returns a list of tags that match the given [tagName] and have a tag value. - */ -fun TagArray.filterByTagWithValue(tagName: String) = this.filter { it.size > 1 && it[0] == tagName } - -/** - * Returns the first tag that match the given [tagName] and have a tag value. - */ -fun TagArray.firstTag(key: String) = this.firstOrNull { it.size > 1 && it[0] == key } - /** * Returns the first tag value that match the given [tagName] and have a tag value. */ -fun TagArray.firstTagValue(key: String) = this.firstOrNull { it.size > 1 && it[0] == key }?.let { it[1] } - -/** - * Returns the first tag value that match the given [tagName] and have a tag value as integer - */ -fun TagArray.firstTagValueAsInt(key: String) = this.firstOrNull { it.size > 1 && it[0] == key }?.let { it[1].toIntOrNull() } +fun TagArray.firstTagValue(key: String) = this.fastFirstOrNull { it.size > 1 && it[0] == key }?.let { it[1] } /** * Returns the first tag value that match the given [tagName] and have a tag value as long */ -fun TagArray.firstTagValueAsLong(key: String) = this.firstOrNull { it.size > 1 && it[0] == key }?.let { it[1].toLongOrNull() } +fun TagArray.firstTagValueAsLong(key: String) = this.fastFirstOrNull { it.size > 1 && it[0] == key }?.let { it[1].toLongOrNull() } /** * Returns the first tag value that match any of the given [tagNames] and have a tag value */ -fun TagArray.firstTagValueFor(vararg tagNames: String) = this.firstOrNull { it.size > 1 && it[0] in tagNames }?.let { it[1] } +fun TagArray.firstTagValueFor(vararg tagNames: String) = this.fastFirstOrNull { it.size > 1 && it[0] in tagNames }?.let { it[1] } /** * Returns `true` if at least one tag matches the given [tagName] and [tagValue] @@ -162,7 +134,7 @@ fun TagArray.isTagged( tagName: String, tagValue: String, ignoreCase: Boolean = false, -) = this.any { it.size > 1 && it[0] == tagName && it[1].equals(tagValue, ignoreCase) } +) = this.fastAny { it.size > 1 && it[0] == tagName && it[1].equals(tagValue, ignoreCase) } /** * Returns `true` if at least one tag matches the given [tagName] and is in [tagValues] @@ -170,27 +142,16 @@ fun TagArray.isTagged( fun TagArray.isAnyTagged( tagName: String, tagValues: Set, -) = this.any { it.size > 1 && it[0] == tagName && it[1] in tagValues } +) = this.fastAny { it.size > 1 && it[0] == tagName && it[1] in tagValues } fun TagArray.any( predicate: (Array, U) -> Boolean, extras: U, ): Boolean { - for (element in this) if (predicate(element, extras)) return true - return false -} - -public inline fun Array.firstNotNullOfOrNull( - transform: (T, U) -> R?, - extras: U, -): R? { - for (element in this) { - val result = transform(element, extras) - if (result != null) { - return result - } + for (index in indices) { + if (predicate(get(index), extras)) return true } - return null + return false } /** @@ -199,7 +160,7 @@ public inline fun Array.firstNotNullOfOrNull( fun TagArray.firstAnyLowercaseTaggedValue( tagName: String, tagValues: Set, -) = this.firstOrNull { it.size > 1 && it[0] == tagName && it[1].lowercase() in tagValues }?.getOrNull(1) +) = this.fastFirstOrNull { it.size > 1 && it[0] == tagName && it[1].lowercase() in tagValues }?.getOrNull(1) /** * Returns `true` if at least one tag matches the given [tagName] and is in [tagValues] @@ -207,15 +168,7 @@ fun TagArray.firstAnyLowercaseTaggedValue( fun TagArray.isAnyLowercaseTagged( tagName: String, tagValues: Set, -) = this.any { it.size > 1 && it[0] == tagName && it[1].lowercase() in tagValues } - -/** - * Returns `true` if at least one tag matches the given [tagName] and is in [tagValues] - */ -fun TagArray.firstAnyTaggedValue( - tagName: String, - tagValues: Set, -) = this.firstOrNull { it.size > 1 && it[0] == tagName && it[1] in tagValues }?.getOrNull(1) +) = this.fastAny { it.size > 1 && it[0] == tagName && it[1].lowercase() in tagValues } /** * Returns `true` if at least one tag has value that contains [text] @@ -223,12 +176,12 @@ fun TagArray.firstAnyTaggedValue( fun TagArray.tagValueContains( text: String, ignoreCase: Boolean = false, -) = this.any { it.size > 1 && it[1].contains(text, ignoreCase) } +) = this.fastAny { it.size > 1 && it[1].contains(text, ignoreCase) } fun TagArray.containsAllTagNamesWithValues(names: Set): Boolean { val remaining = names.toMutableSet() - this.forEach { + this.fastForEach { if (it.size > 1) { remaining.remove(it[0]) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt index aab802bedb..bfe3f9d6fc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/DeterministicSigner.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/DeterministicSigner.kt index 495d26c417..2b3e1956a3 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/DeterministicSigner.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/DeterministicSigner.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/EventAssembler.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/EventAssembler.kt index 4ed4133d83..b6fd97e0f5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/EventAssembler.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/EventAssembler.kt @@ -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 @@ -48,7 +48,7 @@ class EventAssembler { tags, content, sig, - ) as T + ) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/EventHasher.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/EventHasher.kt index 591b2695b4..5fcfd9e8cd 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/EventHasher.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/EventHasher.kt @@ -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 com.fasterxml.jackson.databind.node.ArrayNode import com.fasterxml.jackson.databind.node.JsonNodeFactory import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.utils.sha256.sha256 class EventHasher { @@ -61,7 +61,7 @@ class EventHasher { kind: Int, tags: Array>, content: String, - ): String = EventMapper.toJson(makeJsonObjectForId(pubKey, createdAt, kind, tags, content)) + ): String = JsonMapper.toJson(makeJsonObjectForId(pubKey, createdAt, kind, tags, content)) fun hashIdBytes( pubKey: HexKey, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/KeyPair.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/KeyPair.kt index 0fd06d11d3..68b978d544 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/KeyPair.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/KeyPair.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/Nip01.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/Nip01.kt index d0cb443401..98714ff69b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/Nip01.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/crypto/Nip01.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/EventHintBundle.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/EventHintBundle.kt index 4f464f9e65..56961814c2 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/EventHintBundle.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/EventHintBundle.kt @@ -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,11 +22,13 @@ package com.vitorpamplona.quartz.nip01Core.hints import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag +import com.vitorpamplona.quartz.nip18Reposts.quotes.QEventTag import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.pointerSizeInBytes @@ -35,10 +37,10 @@ import com.vitorpamplona.quartz.utils.pointerSizeInBytes data class EventHintBundle( val event: T, ) { - var relay: String? = null - var authorHomeRelay: String? = null + var relay: NormalizedRelayUrl? = null + var authorHomeRelay: NormalizedRelayUrl? = null - constructor(event: T, relayHint: String? = null, authorHomeRelay: String? = null) : this(event) { + constructor(event: T, relayHint: NormalizedRelayUrl? = null, authorHomeRelay: NormalizedRelayUrl? = null) : this(event) { this.relay = relayHint this.authorHomeRelay = authorHomeRelay } @@ -46,7 +48,7 @@ data class EventHintBundle( fun countMemory(): Long = 2 * pointerSizeInBytes + // 2 fields, 4 bytes each reference (32bit) event.countMemory() + - (relay?.bytesUsedInMemory() ?: 0) + (relay?.url?.bytesUsedInMemory() ?: 0) fun toNEvent(): String = NEvent.create(event.id, event.pubKey, event.kind, relay) @@ -60,5 +62,5 @@ data class EventHintBundle( fun toETagArray() = ETag.assemble(event.id, relay, event.pubKey) - fun toQTagArray() = ETag(event.id, relay, event.pubKey).toQTagArray() + fun toQTagArray() = QEventTag.assemble(event.id, relay, event.pubKey) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/HintIndexer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/HintIndexer.kt index efee3a2be5..99fc4a1c13 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/HintIndexer.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/HintIndexer.kt @@ -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,6 +23,9 @@ package com.vitorpamplona.quartz.nip01Core.hints import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.hints.bloom.BloomFilterMurMur3 +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost +import com.vitorpamplona.quartz.utils.LargeCache /** * Instead of having one bloom filter per relay per type, which could create @@ -34,70 +37,72 @@ class HintIndexer { private val eventHints = BloomFilterMurMur3(10_000_000, 5) private val addressHints = BloomFilterMurMur3(2_000_000, 5) private val pubKeyHints = BloomFilterMurMur3(10_000_000, 5) - private val relayDB = mutableSetOf() + private val relayDB = LargeCache() private fun add( id: ByteArray, - relay: String, + relay: NormalizedRelayUrl, bloom: BloomFilterMurMur3, ) { - relayDB.add(relay) - bloom.add(id, relay.hashCode()) + if (!relay.isLocalHost()) { + relayDB.put(relay, relay) + bloom.add(id, relay.hashCode()) + } } - private fun get( + private fun getHintsFor( id: ByteArray, bloom: BloomFilterMurMur3, - ) = relayDB.filter { bloom.mightContain(id, it.hashCode()) } + ) = relayDB.filter { relay, _ -> bloom.mightContain(id, relay.hashCode()) } // -------------------- // Event Host hints // -------------------- fun addEvent( eventId: ByteArray, - relay: String, + relay: NormalizedRelayUrl, ) = add(eventId, relay, eventHints) fun addEvent( eventId: HexKey, - relay: String, + relay: NormalizedRelayUrl, ) = addEvent(eventId.hexToByteArray(), relay) - fun getEvent(eventId: ByteArray) = get(eventId, eventHints) + fun hintsForEvent(eventId: ByteArray) = getHintsFor(eventId, eventHints) - fun getEvent(eventId: HexKey) = getEvent(eventId.hexToByteArray()) + fun hintsForEvent(eventId: HexKey) = hintsForEvent(eventId.hexToByteArray()) // -------------------- // PubKeys Outbox hints // -------------------- fun addAddress( addressId: ByteArray, - relay: String, + relay: NormalizedRelayUrl, ) = add(addressId, relay, addressHints) fun addAddress( addressId: String, - relay: String, + relay: NormalizedRelayUrl, ) = addAddress(addressId.toByteArray(), relay) - fun getAddress(addressId: ByteArray) = get(addressId, addressHints) + fun hintsForAddress(addressId: ByteArray) = getHintsFor(addressId, addressHints) - fun getAddress(addressId: String) = getAddress(addressId.toByteArray()) + fun hintsForAddress(addressId: String) = hintsForAddress(addressId.toByteArray()) // -------------------- // PubKeys Outbox hints // -------------------- fun addKey( key: ByteArray, - relay: String, + relay: NormalizedRelayUrl, ) = add(key, relay, pubKeyHints) fun addKey( key: HexKey, - relay: String, + relay: NormalizedRelayUrl, ) = addKey(key.hexToByteArray(), relay) - fun getKey(key: ByteArray) = get(key, pubKeyHints) + fun hintsForKey(key: ByteArray) = getHintsFor(key, pubKeyHints) - fun getKey(key: HexKey) = getKey(key.hexToByteArray()) + fun hintsForKey(key: HexKey) = hintsForKey(key.hexToByteArray()) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/HintProviders.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/HintProviders.kt index aa94e3ca5c..704ebd770a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/HintProviders.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/HintProviders.kt @@ -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,18 +20,25 @@ */ package com.vitorpamplona.quartz.nip01Core.hints +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint interface EventHintProvider { fun eventHints(): List + + fun linkedEventIds(): List } interface AddressHintProvider { fun addressHints(): List + + fun linkedAddressIds(): List } interface PubKeyHintProvider { fun pubKeyHints(): List + + fun linkedPubKeys(): List } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/bloom/BitSetExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/bloom/BitSetExt.kt index be4ef3b55c..bb4473c54a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/bloom/BitSetExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/bloom/BitSetExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/bloom/BloomFilterMurMur3.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/bloom/BloomFilterMurMur3.kt index e4e96b320a..1ed20707ec 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/bloom/BloomFilterMurMur3.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/bloom/BloomFilterMurMur3.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/bloom/MurmurHash3.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/bloom/MurmurHash3.kt index 280ef14391..d69b0d912d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/bloom/MurmurHash3.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/bloom/MurmurHash3.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/AddressHint.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/AddressHint.kt index e83191bc07..9292790a55 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/AddressHint.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/AddressHint.kt @@ -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,9 +20,11 @@ */ package com.vitorpamplona.quartz.nip01Core.hints.types -class AddressHint( +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +data class AddressHint( val addressId: String, - var relay: String? = null, + val relay: NormalizedRelayUrl, ) : Hint { override fun id() = addressId.toByteArray(Charsets.UTF_8) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/EventIdHint.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/EventIdHint.kt index 80faa2149d..556cb221b4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/EventIdHint.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/EventIdHint.kt @@ -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,10 +22,18 @@ package com.vitorpamplona.quartz.nip01Core.hints.types import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -class EventIdHint( +data class EventIdHint( val eventId: HexKey, - var relay: String? = null, + val relay: NormalizedRelayUrl, +) : Hint { + override fun id() = eventId.hexToByteArray() +} + +data class EventIdHintOptional( + val eventId: HexKey, + val relay: NormalizedRelayUrl? = null, ) : Hint { override fun id() = eventId.hexToByteArray() } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/Hint.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/Hint.kt index 591a3e6858..991453e80f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/Hint.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/Hint.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/PubKeyHint.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/PubKeyHint.kt index 431c121215..f514f68a49 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/PubKeyHint.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/PubKeyHint.kt @@ -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,10 +22,11 @@ package com.vitorpamplona.quartz.nip01Core.hints.types import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -class PubKeyHint( +data class PubKeyHint( val pubkey: HexKey, - var relay: String? = null, + val relay: NormalizedRelayUrl, ) : Hint { override fun id() = pubkey.hexToByteArray() } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventDeserializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventDeserializer.kt index 527b4b3391..284a6459ad 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventDeserializer.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventDeserializer.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventManualDeserializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventManualDeserializer.kt index 93bfaa7255..f5cadf3be8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventManualDeserializer.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventManualDeserializer.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventManualSerializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventManualSerializer.kt index 2d19402771..5351028e0a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventManualSerializer.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventManualSerializer.kt @@ -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,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey class EventManualSerializer { companion object { - private fun assemble( + fun assemble( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -67,23 +67,7 @@ class EventManualSerializer { sig: String, ): String { val obj = assemble(id, pubKey, createdAt, kind, tags, content, sig) - return EventMapper.mapper.writeValueAsString(obj) - } - - /** - * For debug purposes only - */ - fun toPrettyJson( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - sig: String, - ): String { - val obj = assemble(id, pubKey, createdAt, kind, tags, content, sig) - return EventMapper.mapper.writerWithDefaultPrettyPrinter().writeValueAsString(obj) + return JsonMapper.mapper.writeValueAsString(obj) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventSerializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventSerializer.kt index 33f3ab70d8..0e665defd0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventSerializer.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventSerializer.kt @@ -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,6 +24,7 @@ import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.databind.SerializerProvider import com.fasterxml.jackson.databind.ser.std.StdSerializer import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlin.collections.indices class EventSerializer : StdSerializer(Event::class.java) { override fun serialize( @@ -37,7 +38,13 @@ class EventSerializer : StdSerializer(Event::class.java) { gen.writeNumberField("created_at", event.createdAt) gen.writeNumberField("kind", event.kind) gen.writeArrayFieldStart("tags") - event.tags.forEach { tag -> gen.writeArray(tag, 0, tag.size) } + for (i in event.tags.indices) { + gen.writeStartArray() + for (j in event.tags[i].indices) { + gen.writeString(event.tags[i][j]) + } + gen.writeEndArray() + } gen.writeEndArray() gen.writeStringField("content", event.content) gen.writeStringField("sig", event.sig) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/InliningTagArrayPrettyPrinter.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/InliningTagArrayPrettyPrinter.kt index 97c34ebfcf..f7b241545f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/InliningTagArrayPrettyPrinter.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/InliningTagArrayPrettyPrinter.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/JacksonExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/JacksonExt.kt index d6ddaeb774..da869eb585 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/JacksonExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/JacksonExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventMapper.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/JsonMapper.kt similarity index 76% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventMapper.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/JsonMapper.kt index 4cceec24d5..ea75d82bf5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/EventMapper.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/JsonMapper.kt @@ -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 @@ -35,11 +35,17 @@ import com.vitorpamplona.quartz.nip47WalletConnect.Request import com.vitorpamplona.quartz.nip47WalletConnect.RequestDeserializer import com.vitorpamplona.quartz.nip47WalletConnect.Response import com.vitorpamplona.quartz.nip47WalletConnect.ResponseDeserializer +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResultJsonDeserializer +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResultJsonSerializer +import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.Permission +import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.PermissionDeserializer +import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.PermissionSerializer import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorDeserializer import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorSerializer -class EventMapper { +class JsonMapper { companion object { val defaultPrettyPrinter = InliningTagArrayPrettyPrinter() @@ -60,7 +66,13 @@ class EventMapper { .addSerializer(BunkerRequest::class.java, BunkerRequest.BunkerRequestSerializer()) .addDeserializer(BunkerRequest::class.java, BunkerRequest.BunkerRequestDeserializer()) .addSerializer(BunkerResponse::class.java, BunkerResponse.BunkerResponseSerializer()) - .addDeserializer(BunkerResponse::class.java, BunkerResponse.BunkerResponseDeserializer()), + .addDeserializer(BunkerResponse::class.java, BunkerResponse.BunkerResponseDeserializer()) + .addDeserializer(Permission::class.java, PermissionDeserializer()) + .addSerializer(Permission::class.java, PermissionSerializer()) + .addDeserializer(IntentResult::class.java, IntentResultJsonDeserializer()) + .addSerializer(IntentResult::class.java, IntentResultJsonSerializer()) + .addDeserializer(Array>::class.java, TagArrayDeserializer()) + .addSerializer(Array>::class.java, TagArraySerializer()), ) fun fromJson(json: String): Event = mapper.readValue(json, Event::class.java) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/TagArrayDeserializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/TagArrayDeserializer.kt new file mode 100644 index 0000000000..fbc44abe32 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/TagArrayDeserializer.kt @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.jackson + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.deser.std.StdDeserializer + +class TagArrayDeserializer : StdDeserializer>>(Array>::class.java) { + override fun deserialize( + jp: JsonParser, + ctxt: DeserializationContext, + ): Array> = TagArrayManualDeserializer.fromJson(jp.codec.readTree(jp)) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/HashtagId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/TagArrayManualDeserializer.kt similarity index 73% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/HashtagId.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/TagArrayManualDeserializer.kt index 7ce563364c..26468af33f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/HashtagId.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/TagArrayManualDeserializer.kt @@ -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,21 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip73ExternalIds +package com.vitorpamplona.quartz.nip01Core.jackson -class HashtagId( - val topic: String, - val hint: String? = null, -) : ExternalId { - override fun toScope() = toScope(topic) - - override fun toKind() = toKind(topic) - - override fun hint() = hint +import com.fasterxml.jackson.databind.JsonNode +class TagArrayManualDeserializer { companion object { - fun toScope(topic: String) = "#" + topic.lowercase() - - fun toKind(topic: String) = "#" + fun fromJson(jsonObject: JsonNode): Array> = + jsonObject.toTypedArray { + it.toTypedArray { s -> if (s.isNull) "" else s.asText().intern() } + } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/TagArraySerializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/TagArraySerializer.kt new file mode 100644 index 0000000000..6152a1c5e9 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/jackson/TagArraySerializer.kt @@ -0,0 +1,43 @@ +/** + * 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.quartz.nip01Core.jackson + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.ser.std.StdSerializer + +class TagArraySerializer : StdSerializer>>(Array>::class.java) { + override fun serialize( + tags: Array>, + gen: JsonGenerator, + provider: SerializerProvider, + ) { + gen.writeStartArray() + for (i in tags.indices) { + gen.writeStartArray() + for (j in tags[i].indices) { + gen.writeString(tags[i][j]) + } + gen.writeEndArray() + } + gen.writeEndArray() + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt index be7172f11f..7a0ab6f327 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt @@ -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,11 +24,11 @@ import android.util.Log import com.fasterxml.jackson.databind.node.JsonNodeFactory import com.fasterxml.jackson.databind.node.ObjectNode import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.builder -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip01Core.metadata.tags.AboutTag import com.vitorpamplona.quartz.nip01Core.metadata.tags.BannerTag import com.vitorpamplona.quartz.nip01Core.metadata.tags.DisplayNameTag @@ -58,12 +58,12 @@ class MetadataEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { fun contactMetadataJson() = jacksonObjectMapper().readTree(content) as? ObjectNode fun contactMetaData() = try { - EventMapper.mapper.readValue(content, UserMetadata::class.java) + JsonMapper.mapper.readValue(content, UserMetadata::class.java) } catch (e: Exception) { Log.w("MetadataEvent", "Content Parse Error: ${toNostrUri()} ${e.localizedMessage}") null diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/TagArrayBuilderExt.kt index 0913027f1f..fe0db61035 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt index 10c8513df0..a97e82157b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/AboutTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/AboutTag.kt index dfe15e9b69..d10dfa8505 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/AboutTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/AboutTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/BannerTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/BannerTag.kt index 6189b81c6e..e150e27fe3 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/BannerTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/BannerTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/DisplayNameTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/DisplayNameTag.kt index d96b32d520..60794e9b57 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/DisplayNameTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/DisplayNameTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/Lud06Tag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/Lud06Tag.kt index 208af58652..afb318d58b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/Lud06Tag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/Lud06Tag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/Lud16Tag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/Lud16Tag.kt index 120c28f632..3ea2c8850e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/Lud16Tag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/Lud16Tag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/NameTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/NameTag.kt index 3b4108d1d7..85e1cdbdd9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/NameTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/NameTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/Nip05Tag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/Nip05Tag.kt index 67c33fc8ab..969c20412c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/Nip05Tag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/Nip05Tag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/PictureTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/PictureTag.kt index 782e27bd8c..da036d7c0a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/PictureTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/PictureTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/PronounsTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/PronounsTag.kt index 5ebf5a3827..2d99d62bca 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/PronounsTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/PronounsTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/WebsiteTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/WebsiteTag.kt index 71468bd63b..b6f66622a9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/WebsiteTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/metadata/tags/WebsiteTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/SimpleClientRelay.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/SimpleClientRelay.kt deleted file mode 100644 index 02978d4794..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/SimpleClientRelay.kt +++ /dev/null @@ -1,480 +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.quartz.nip01Core.relay - -import android.util.Log -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ToClientParser -import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd -import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd -import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd -import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd -import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket -import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener -import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder -import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.utils.bytesUsedInMemory -import java.util.concurrent.atomic.AtomicBoolean -import kotlin.coroutines.cancellation.CancellationException - -class SimpleClientRelay( - val url: String, - val socketBuilder: WebsocketBuilder, - val subs: SubscriptionCollection, - val listener: Listener, - val stats: RelayStat = RelayStat(), -) { - companion object { - // waits 3 minutes to reconnect once things fail - const val RECONNECTING_IN_SECONDS = 60 * 3 - } - - private var socket: WebSocket? = null - private var isReady: Boolean = false - private var usingCompression: Boolean = false - - private var lastConnectTentative: Long = 0L - - private var afterEOSEPerSubscription = mutableMapOf() - - private val authResponseWatcher = mutableMapOf() - private val authChallengesSent = mutableSetOf() - - /** - * Auth procedures require us to keep track of the outgoing events - * to make sure the relay waits for the auth to finish and send them. - */ - private val outboxCache = mutableMapOf() - - private var connectingMutex = AtomicBoolean() - - private val parser = ToClientParser() - - fun isConnectionStarted(): Boolean = socket != null - - fun isConnected(): Boolean = socket != null && isReady - - fun connect() = connectAndRunOverride(::sendEverything) - - fun sendEverything() { - renewSubscriptions() - sendOutbox() - } - - fun sendOutbox() { - synchronized(outboxCache) { - outboxCache.values.forEach { - send(it) - } - } - } - - fun connectAndRunAfterSync(onConnected: () -> Unit) { - connectAndRunOverride { - sendEverything() - onConnected() - } - } - - fun connectAndRunOverride(onConnected: () -> Unit) { - Log.d("Relay", "Relay.connect $url isAlreadyConnecting: ${connectingMutex.get()}") - - // If there is a connection, don't wait. - if (connectingMutex.getAndSet(true)) { - return - } - - try { - if (socket != null) { - connectingMutex.set(false) - return - } - - lastConnectTentative = TimeUtils.now() - - socket = socketBuilder.build(url, RelayListener(onConnected)) - socket?.connect() - } catch (e: Exception) { - if (e is CancellationException) throw e - - stats.newError(e.message ?: "Error trying to connect: ${e.javaClass.simpleName}") - - markConnectionAsClosed() - e.printStackTrace() - } finally { - connectingMutex.set(false) - } - } - - inner class RelayListener( - val onConnected: () -> Unit, - ) : WebSocketListener { - override fun onOpen( - pingMillis: Long, - compression: Boolean, - ) { - Log.d("Relay", "Connect onOpen $url $socket") - - markConnectionAsReady(pingMillis, compression) - - // Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url") - onConnected() - - listener.onRelayStateChange(this@SimpleClientRelay, RelayState.CONNECTED) - } - - override fun onMessage(text: String) { - stats.addBytesReceived(text.bytesUsedInMemory()) - - try { - processNewRelayMessage(text) - } catch (e: Throwable) { - if (e is CancellationException) throw e - stats.newError("Error processing: $text") - Log.e("Relay", "Error processing: $text") - listener.onError(this@SimpleClientRelay, "", Error("Error processing $text")) - } - } - - override fun onClosing( - code: Int, - reason: String, - ) { - Log.w("Relay", "Relay onClosing $url: $reason") - - listener.onRelayStateChange(this@SimpleClientRelay, RelayState.DISCONNECTING) - } - - override fun onClosed( - code: Int, - reason: String, - ) { - markConnectionAsClosed() - - Log.w("Relay", "Relay onClosed $url: $reason") - - listener.onRelayStateChange(this@SimpleClientRelay, RelayState.DISCONNECTED) - } - - override fun onFailure( - t: Throwable, - response: String?, - ) { - socket?.cancel() // 1000, "Normal close" - - // checks if this is an actual failure. Closing the socket generates an onFailure as well. - if (!(socket == null && (t.message == "Socket is closed" || t.message == "Socket closed"))) { - stats.newError(response ?: t.message ?: "onFailure event from server: ${t.javaClass.simpleName}") - } - - // Failures disconnect the relay. - markConnectionAsClosed() - - Log.w("Relay", "Relay onFailure $url, $response $response ${t.message} $socket") - t.printStackTrace() - listener.onError( - this@SimpleClientRelay, - "", - Error("WebSocket Failure. Response: $response. Exception: ${t.message}", t), - ) - } - } - - fun markConnectionAsReady( - pingInMs: Long, - usingCompression: Boolean, - ) { - this.resetEOSEStatuses() - this.isReady = true - this.usingCompression = usingCompression - - stats.pingInMs = pingInMs - } - - fun markConnectionAsClosed() { - this.socket = null - this.isReady = false - this.usingCompression = false - this.resetEOSEStatuses() - } - - fun processNewRelayMessage(newMessage: String) { - when (val msg = parser.parse(newMessage)) { - is EventMessage -> { - // Log.w("Relay", "Relay onEVENT $url $newMessage") - listener.onEvent(this, msg.subId, msg.event, afterEOSEPerSubscription[msg.subId] == true) - } - is EoseMessage -> { - // Log.w("Relay", "Relay onEOSE $url $newMessage") - afterEOSEPerSubscription[msg.subId] = true - listener.onEOSE(this@SimpleClientRelay, msg.subId) - } - is NoticeMessage -> { - // Log.w("Relay", "Relay onNotice $url, $newMessage") - stats.newNotice(msg.message) - listener.onError(this@SimpleClientRelay, msg.message, Error("Relay sent notice: $msg.message")) - } - is OkMessage -> { - Log.w("Relay", "Relay on OK $url, $newMessage") - - // if this is the OK of an auth event, renew all subscriptions and resend all outgoing events. - if (authResponseWatcher.containsKey(msg.eventId)) { - val wasAlreadyAuthenticated = authResponseWatcher[msg.eventId] - authResponseWatcher.put(msg.eventId, msg.success) - if (wasAlreadyAuthenticated != true && msg.success) { - sendEverything() - } - } - - // remove from cache for any error that is not an auth required error. - // for auth required, we will do the auth and try to send again. - if (outboxCache.contains(msg.eventId) && !msg.message.startsWith("auth-required")) { - synchronized(outboxCache) { - outboxCache.remove(msg.eventId) - } - } - - if (!msg.success) { - stats.newNotice("Rejected event ${msg.eventId}: ${msg.message}") - } - - listener.onSendResponse(this@SimpleClientRelay, msg.eventId, msg.success, msg.message) - } - is AuthMessage -> { - // Log.d("Relay", "Relay onAuth $url, $newMessage") - listener.onAuth(this@SimpleClientRelay, msg.challenge) - } - is NotifyMessage -> { - // Log.w("Relay", "Relay onNotify $url, $newMessage") - listener.onNotify(this@SimpleClientRelay, msg.message) - } - is ClosedMessage -> { - // Log.w("Relay", "Relay Closed Subscription $url, $newMessage") - listener.onClosed(this@SimpleClientRelay, msg.subscriptionId, msg.message) - } - else -> { - stats.newError("Unsupported message: $newMessage") - Log.w("Relay", "Unsupported message: $newMessage") - listener.onError(this, "", Error("Unsupported message: $newMessage")) - } - } - } - - fun disconnect() { - Log.d("Relay", "Relay.disconnect $url") - lastConnectTentative = 0L // this is not an error, so prepare to reconnect as soon as requested. - socket?.cancel() - socket = null - isReady = false - usingCompression = false - resetEOSEStatuses() - } - - fun resetEOSEStatuses() { - afterEOSEPerSubscription = LinkedHashMap(afterEOSEPerSubscription.size) - - authResponseWatcher.clear() - authChallengesSent.clear() - } - - fun sendRequest( - requestId: String, - filters: List, - ) { - if (isConnectionStarted()) { - if (isReady) { - if (filters.isNotEmpty()) { - writeToSocket(ReqCmd.toJson(requestId, filters)) - afterEOSEPerSubscription[requestId] = false - } - } - } else { - // waits 60 seconds to reconnect after disconnected. - if (TimeUtils.now() > lastConnectTentative + RECONNECTING_IN_SECONDS) { - // sends all filters after connection is successful. - connect() - } - } - } - - fun sendCount( - requestId: String, - filters: List, - ) { - if (isConnectionStarted()) { - if (isReady) { - if (filters.isNotEmpty()) { - writeToSocket(CountCmd.toJson(requestId, filters)) - afterEOSEPerSubscription[requestId] = false - } - } - } else { - // waits 60 seconds to reconnect after disconnected. - if (TimeUtils.now() > lastConnectTentative + RECONNECTING_IN_SECONDS) { - // sends all filters after connection is successful. - connect() - } - } - } - - fun connectAndSendFiltersIfDisconnected() { - if (socket == null) { - // waits 60 seconds to reconnect after disconnected. - if (TimeUtils.now() > lastConnectTentative + RECONNECTING_IN_SECONDS) { - connect() - } - } - } - - fun renewSubscriptions() { - // Force update all filters after AUTH. - subs.allSubscriptions().forEach { - sendRequest(requestId = it.id, it.filters) - } - } - - fun send(signedEvent: Event) { - listener.onBeforeSend(this@SimpleClientRelay, signedEvent) - - if (signedEvent is RelayAuthEvent) { - sendAuth(signedEvent) - } else { - sendEvent(signedEvent) - } - } - - fun sendAuth(signedEvent: RelayAuthEvent) { - val challenge = signedEvent.challenge() - - // only send replies to new challenges to avoid infinite loop: - if (challenge != null && challenge !in authChallengesSent) { - authResponseWatcher[signedEvent.id] = false - authChallengesSent.add(challenge) - writeToSocket(AuthCmd.toJson(signedEvent)) - } - } - - fun sendEvent(signedEvent: Event) { - synchronized(outboxCache) { - outboxCache.put(signedEvent.id, signedEvent) - } - - if (isConnectionStarted()) { - if (isReady) { - writeToSocket(EventCmd.toJson(signedEvent)) - } - } else { - // automatically sends all filters after connection is successful. - connect() - } - } - - private fun writeToSocket(str: String) { - if (socket == null) { - listener.onError( - this@SimpleClientRelay, - "", - Error("Failed to send $str. Relay is not connected."), - ) - } - socket?.let { - val result = it.send(str) - listener.onSend(this@SimpleClientRelay, str, result) - stats.addBytesSent(str.bytesUsedInMemory()) - - Log.d("Relay", "Relay send $url (${str.length} chars) $str") - } - } - - fun close(subscriptionId: String) { - writeToSocket(CloseCmd.toJson(subscriptionId)) - } - - interface Listener { - fun onEvent( - relay: SimpleClientRelay, - subscriptionId: String, - event: Event, - afterEOSE: Boolean, - ) - - fun onEOSE( - relay: SimpleClientRelay, - subscriptionId: String, - ) - - fun onError( - relay: SimpleClientRelay, - subscriptionId: String, - error: Error, - ) - - fun onAuth( - relay: SimpleClientRelay, - challenge: String, - ) - - fun onRelayStateChange( - relay: SimpleClientRelay, - type: RelayState, - ) - - fun onNotify( - relay: SimpleClientRelay, - description: String, - ) - - fun onClosed( - relay: SimpleClientRelay, - subscriptionId: String, - message: String, - ) - - fun onBeforeSend( - relay: SimpleClientRelay, - event: Event, - ) - - fun onSend( - relay: SimpleClientRelay, - msg: String, - success: Boolean, - ) - - fun onSendResponse( - relay: SimpleClientRelay, - eventId: String, - success: Boolean, - message: String, - ) - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt new file mode 100644 index 0000000000..d741251b7b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt @@ -0,0 +1,392 @@ +/** + * 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.quartz.nip01Core.relay.client + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.PoolEventOutboxRepository +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.PoolSubscriptionRepository +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayPool +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.flow.stateIn + +/** + * The NostrClient manages Nostr relay operations, subscriptions, and event delivery. It maintains: + * - A RelayPool for managing connections to a collection of Nostr relays + * - Active subscriptions tracking through PoolSubscriptionRepository + * - An event outbox for managing unsent events and retry logic + * - Automatic relay pool reconciliation based on subscription and event needs + * + * Core responsibilities include: + * - Initializing and managing relay connections using WebSocket builders + * - Coordinating subscription state across multiple relays + * - Handling event and filter resending when relays disconnect and reconnect + * - Aggregating relay status from the pool's state flow + * - Maintaining listeners for propagating relay events and state changes + * + * Features: + * - Reactive updating of relay sets based on subscription and outbox activity + * - Relayer reconnection strategy that processes pending requests + * - Filter comparison logic to detect significant subscription changes and avoid redundant requests + * - Integration with RelayStats for tracking relay performance + * - Thread-safe reconnection methods using coroutine flows + * + * The class combines flows from active subscriptions and the event outbox to ensure relays + * are connected to all necessary endpoints. It also listens to relay state changes to update + * subscriptions and message retries when connections are re-established. + */ +class NostrClient( + private val websocketBuilder: WebsocketBuilder, + private val scope: CoroutineScope, +) : IRelayClientListener { + private val relayPool: RelayPool = RelayPool(this, ::buildRelay) + private val activeRequests: PoolSubscriptionRepository = PoolSubscriptionRepository() + private val activeCounts: PoolSubscriptionRepository = PoolSubscriptionRepository() + private val eventOutbox: PoolEventOutboxRepository = PoolEventOutboxRepository() + + private var listeners = setOf() + + // controls the state of the client in such a way that if it is active + // new filters will be sent to the relays and a potential reconnect can + // be triggered. + private var isActive = false + + /** + * Whatches for any changes in the relay list from subscriptions or outbox + * and updates the relayPool as needed. + */ + @OptIn(FlowPreview::class) + private val allRelays = + combine( + activeRequests.relays, + activeCounts.relays, + eventOutbox.relays, + ) { reqs, counts, outbox -> + reqs + counts + outbox + }.sample(300) + .onEach { + relayPool.updatePool(it) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Companion.Eagerly, + activeRequests.relays.value + activeCounts.relays.value + eventOutbox.relays.value, + ) + + fun buildRelay(relay: NormalizedRelayUrl): IRelayClient = + BasicRelayClient( + url = relay, + socketBuilder = websocketBuilder, + listener = relayPool, + stats = RelayStats.get(relay), + scope = scope, + ) { liveRelay -> + if (isActive) { + activeRequests.forEachSub(relay, liveRelay::sendRequest) + activeCounts.forEachSub(relay, liveRelay::sendCount) + eventOutbox.forEachUnsentEvent(relay, liveRelay::send) + } + } + + fun allAvailableRelays() = relayPool.getAll() + + // Reconnects all relays that may have disconnected + fun connect() { + isActive = true + relayPool.connect() + } + + fun disconnect() { + isActive = false + relayPool.disconnect() + } + + @Synchronized + fun reconnect(onlyIfChanged: Boolean = false) { + if (onlyIfChanged) { + relayPool.getAllNeedsToReconnect().forEach { + it.disconnect() + } + relayPool.connect() + } else { + relayPool.disconnect() + relayPool.connect() + } + } + + fun needsToResendRequest( + oldFilters: List, + newFilters: List, + ): Boolean { + if (oldFilters.size != newFilters.size) return true + + oldFilters.forEachIndexed { index, oldFilter -> + val newFilter = newFilters.getOrNull(index) ?: return true + + return needsToResendRequest(oldFilter, newFilter) + } + return false + } + + /** + * Checks if the filter has changed, with a special case for when the since changes due to new + * EOSE times. + */ + fun needsToResendRequest( + oldFilter: Filter, + newFilter: Filter, + ): Boolean { + // Does not check SINCE on purpose. Avoids replacing the filter if SINCE was all that changed. + // fast check + if (oldFilter.authors?.size != newFilter.authors?.size || + oldFilter.ids?.size != newFilter.ids?.size || + oldFilter.tags?.size != newFilter.tags?.size || + oldFilter.kinds?.size != newFilter.kinds?.size || + oldFilter.limit != newFilter.limit || + oldFilter.search?.length != newFilter.search?.length || + oldFilter.until != newFilter.until + ) { + return true + } + + // deep check + if (oldFilter.ids != newFilter.ids || + oldFilter.authors != newFilter.authors || + oldFilter.tags != newFilter.tags || + oldFilter.kinds != newFilter.kinds || + oldFilter.search != newFilter.search + ) { + return true + } + + if (oldFilter.since != null) { + if (newFilter.since == null) { + // went was checking the future only and now wants everything + return true + } else if (oldFilter.since > newFilter.since) { + // went backwards in time, forces update + return true + } + } + + return false + } + + fun sendRequest( + subId: String = newSubId(), + filters: Map>, + ) { + val oldFilters = activeRequests.getSubscriptionFiltersOrNull(subId) ?: emptyMap() + activeRequests.addOrUpdate(subId, filters) + + if (isActive) { + val allRelays = filters.keys + oldFilters.keys + + allRelays.forEach { relay -> + val oldFilters = oldFilters[relay] + val newFilters = filters[relay] + + if (newFilters.isNullOrEmpty()) { + // some relays are not in this sub anymore. Stop their subscriptions + relayPool.close(relay, subId) + } else if (oldFilters.isNullOrEmpty()) { + // new relays were added. Start a new sub in them + relayPool.sendRequest(relay, subId, newFilters) + } else if (needsToResendRequest(oldFilters, newFilters)) { + // filters were changed enough (not only an update in since) to warn a new update + relayPool.sendRequest(relay, subId, newFilters) + } else { + // makes sure the relay wakes up if it was disconnected by the server + // upon connection, the relay will run the default Sync and update all + // filters, including this one. + relayPool.connectIfDisconnected(relay) + } + } + } + } + + fun sendCount( + subId: String = newSubId(), + filters: Map>, + ) { + val oldFilters = activeCounts.getSubscriptionFiltersOrNull(subId) ?: emptyMap() + activeCounts.addOrUpdate(subId, filters) + + if (isActive) { + val allRelays = filters.keys + oldFilters.keys + + allRelays.forEach { relay -> + val oldFilters = oldFilters[relay] + val newFilters = filters[relay] + + if (newFilters.isNullOrEmpty()) { + // some relays are not in this sub anymore. Stop their subscriptions + relayPool.close(relay, subId) + } else if (oldFilters.isNullOrEmpty()) { + // new relays were added. Start a new sub in them + relayPool.sendCount(relay, subId, newFilters) + } else if (needsToResendRequest(oldFilters, newFilters)) { + // filters were changed enough (not only an update in since) to warn a new update + relayPool.sendCount(relay, subId, newFilters) + } else { + // makes sure the relay wakes up if it was disconnected by the server + // upon connection, the relay will run the default Sync and update all + // filters, including this one. + relayPool.connectIfDisconnected(relay) + } + } + } + } + + fun sendIfExists( + event: Event, + connectedRelay: NormalizedRelayUrl, + ) { + if (isActive) { + relayPool.getRelay(connectedRelay)?.send(event) + } + } + + fun send( + event: Event, + relayList: Set, + ) { + eventOutbox.markAsSending(event, relayList) + if (isActive) { + relayPool.send(event, relayList) + } + } + + fun close(subscriptionId: String) { + activeRequests.remove(subscriptionId) + activeCounts.remove(subscriptionId) + relayPool.close(subscriptionId) + } + + override fun onEvent( + relay: IRelayClient, + subId: String, + event: Event, + arrivalTime: Long, + afterEOSE: Boolean, + ) { + listeners.forEach { it.onEvent(relay, subId, event, arrivalTime, afterEOSE) } + } + + override fun onEOSE( + relay: IRelayClient, + subId: String, + arrivalTime: Long, + ) { + listeners.forEach { it.onEOSE(relay, subId, arrivalTime) } + } + + override fun onRelayStateChange( + relay: IRelayClient, + type: RelayState, + ) { + listeners.forEach { it.onRelayStateChange(relay, type) } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun onBeforeSend( + relay: IRelayClient, + event: Event, + ) { + eventOutbox.newTry(event.id, relay.url) + listeners.forEach { it.onBeforeSend(relay, event) } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun onSendResponse( + relay: IRelayClient, + eventId: String, + success: Boolean, + message: String, + ) { + eventOutbox.newResponse(eventId, relay.url, success, message) + listeners.forEach { it.onSendResponse(relay, eventId, success, message) } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun onAuth( + relay: IRelayClient, + challenge: String, + ) { + listeners.forEach { it.onAuth(relay, challenge) } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun onNotify( + relay: IRelayClient, + description: String, + ) { + listeners.forEach { it.onNotify(relay, description) } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun onSend( + relay: IRelayClient, + msg: String, + success: Boolean, + ) { + listeners.forEach { it.onSend(relay, msg, success) } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun onError( + relay: IRelayClient, + subId: String, + error: Error, + ) { + listeners.forEach { it.onError(relay, subId, error) } + } + + fun subscribe(listener: IRelayClientListener) { + listeners = listeners.plus(listener) + } + + fun isSubscribed(listener: IRelayClientListener): Boolean = listeners.contains(listener) + + fun unsubscribe(listener: IRelayClientListener) { + listeners = listeners.minus(listener) + } + + fun getSubscriptionFiltersOrNull(subId: String): Map>? = activeRequests.getSubscriptionFiltersOrNull(subId) + + fun relayStatusFlow() = relayPool.statusFlow +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/EventCollector.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/EventCollector.kt new file mode 100644 index 0000000000..4acf6c0835 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/EventCollector.kt @@ -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.quartz.nip01Core.relay.client.accessories + +import android.util.Log +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient + +/** + * Listens to NostrClient's onEvent messages for caching purposes. + */ +class EventCollector( + val client: NostrClient, + val onEvent: (event: Event, relay: IRelayClient) -> Unit, +) { + private val clientListener = + object : IRelayClientListener { + override fun onEvent( + relay: IRelayClient, + subId: String, + event: Event, + arrivalTime: Long, + afterEOSE: Boolean, + ) { + onEvent(event, relay) + } + } + + init { + Log.d("${this.javaClass.simpleName}", "Init, Subscribe") + client.subscribe(clientListener) + } + + fun destroy() { + // makes sure to run + Log.d("${this.javaClass.simpleName}", "Destroy, Unsubscribe") + client.unsubscribe(clientListener) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSendAndWaitExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSendAndWaitExt.kt new file mode 100644 index 0000000000..dc80f0c898 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSendAndWaitExt.kt @@ -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.quartz.nip01Core.relay.client.accessories + +import android.util.Log +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.DelicateCoroutinesApi +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@OptIn(DelicateCoroutinesApi::class) +suspend fun NostrClient.sendAndWaitForResponse( + event: Event, + relayList: Set, + timeoutInSeconds: Long = 15, +): Boolean { + val size = relayList.size + val latch = CountDownLatch(size) + val relayResults = mutableMapOf() + var result = false + + Log.d("sendAndWaitForResponse", "Waiting for $size responses") + + val subscription = + object : IRelayClientListener { + override fun onError( + relay: IRelayClient, + subId: String, + error: Error, + ) { + if (relay.url in relayList && relayResults[relay.url] == null) { + relayResults[relay.url] = false + latch.countDown() + } + Log.d("sendAndWaitForResponse", "onError Error from relay ${relay.url} count: ${latch.count} error: $error") + } + + override fun onRelayStateChange( + relay: IRelayClient, + type: RelayState, + ) { + if (type == RelayState.DISCONNECTED) { + if (relay.url in relayList && relayResults[relay.url] == null) { + relayResults[relay.url] = false + latch.countDown() + } + } + Log.d("sendAndWaitForResponse", "onRelayStateChange ${type.name} from relay ${relay.url} count: ${latch.count}") + } + + override fun onSendResponse( + relay: IRelayClient, + eventId: String, + success: Boolean, + message: String, + ) { + if (eventId == event.id) { + if (relayResults[relay.url] == null) { + latch.countDown() + relayResults[relay.url] = success + } else { + if (success && relayResults[relay.url] == false) { + relayResults[relay.url] = true + } + } + + if (success) { + result = true + } + + Log.d("sendAndWaitForResponse", "onSendResponse Received response for $eventId from relay ${relay.url} count: ${latch.count} message $message success $success") + } + } + } + + subscribe(subscription) + + send(event, relayList) + + latch.await(timeoutInSeconds, TimeUnit.SECONDS) + + unsubscribe(subscription) + + Log.d("sendAndWaitForResponse", "countdown finished") + + return result +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSingleDownloadExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSingleDownloadExt.kt new file mode 100644 index 0000000000..62869bfbb5 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSingleDownloadExt.kt @@ -0,0 +1,66 @@ +/** + * 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.quartz.nip01Core.relay.client.accessories + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +fun NostrClient.downloadFirstEvent( + subscriptionId: String = newSubId(), + filters: Map>, + onResponse: (Event) -> Unit, +) { + val listener = + object : IRelayClientListener { + override fun onEvent( + relay: IRelayClient, + subId: String, + event: Event, + arrivalTime: Long, + afterEOSE: Boolean, + ) { + if (subId == subscriptionId) { + unsubscribe(this) + close(subscriptionId) + + onResponse(event) + } + } + } + + subscribe(listener) + + sendRequest(subscriptionId, filters) + + GlobalScope.launch(Dispatchers.IO) { + delay(30000) + unsubscribe(listener) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayAuthenticator.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayAuthenticator.kt new file mode 100644 index 0000000000..d8c51d9c29 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayAuthenticator.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.accessories + +import android.util.Log +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +class RelayAuthenticator( + val client: NostrClient, + val scope: CoroutineScope, + val authenticate: suspend (challenge: String, relay: IRelayClient) -> Unit, +) { + private val clientListener = + object : IRelayClientListener { + override fun onAuth( + relay: IRelayClient, + challenge: String, + ) { + scope.launch { + authenticate(challenge, relay) + } + } + } + + init { + Log.d("${this.javaClass.simpleName}", "Init, Subscribe") + client.subscribe(clientListener) + } + + fun destroy() { + // makes sure to run + Log.d("${this.javaClass.simpleName}", "Destroy, Unsubscribe") + client.unsubscribe(clientListener) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt new file mode 100644 index 0000000000..b23654bd75 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt @@ -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.quartz.nip01Core.relay.client.accessories + +import android.util.Log +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient + +/** + * Listens to NostrClient's onEvent messages for caching purposes. + */ +class RelayInsertConfirmationCollector( + val client: NostrClient, + val onRelayReceived: (eventId: HexKey, relay: IRelayClient) -> Unit, +) { + private val clientListener = + object : IRelayClientListener { + override fun onSendResponse( + relay: IRelayClient, + eventId: String, + success: Boolean, + message: String, + ) { + if (success) { + onRelayReceived(eventId, relay) + } + } + } + + init { + Log.d("${this.javaClass.simpleName}", "Init, Subscribe") + client.subscribe(clientListener) + } + + fun destroy() { + // makes sure to run + Log.d("${this.javaClass.simpleName}", "Destroy, Unsubscribe") + client.unsubscribe(clientListener) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt new file mode 100644 index 0000000000..40bf64eb76 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.accessories + +import android.util.Log +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient + +/** + * Listens to NostrClient's onNotify messages from the relay + */ +class RelayLogger( + val client: NostrClient, + val notify: (message: String, relay: IRelayClient) -> Unit, +) { + private val clientListener = + object : IRelayClientListener { + /** A new message was received */ + override fun onEvent( + relay: IRelayClient, + subId: String, + event: Event, + arrivalTime: Long, + afterEOSE: Boolean, + ) { + Log.d("Relay", "Relay onEVENT ${relay.url} ($subId - $afterEOSE) ${event.toJson()}") + } + + override fun onSend( + relay: IRelayClient, + msg: String, + success: Boolean, + ) { + Log.d("Relay", "Relay send ${relay.url} (${msg.length} chars) $msg") + } + } + + init { + Log.d("${this.javaClass.simpleName}", "Init, Subscribe") + client.subscribe(clientListener) + } + + fun destroy() { + // makes sure to run + Log.d("${this.javaClass.simpleName}", "Destroy, Unsubscribe") + client.unsubscribe(clientListener) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayNotifier.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayNotifier.kt new file mode 100644 index 0000000000..289d5b95f3 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayNotifier.kt @@ -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.quartz.nip01Core.relay.client.accessories + +import android.util.Log +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient + +/** + * Listens to NostrClient's onNotify messages from the relay + */ +class RelayNotifier( + val client: NostrClient, + val notify: (message: String, relay: IRelayClient) -> Unit, +) { + companion object { + val TAG = RelayNotifier::class.java.simpleName + } + + private val clientListener = + object : IRelayClientListener { + override fun onNotify( + relay: IRelayClient, + message: String, + ) { + notify(message, relay) + } + } + + init { + Log.d(TAG, "Init, Subscribe") + client.subscribe(clientListener) + } + + fun destroy() { + // makes sure to run + Log.d(TAG, "Destroy, Unsubscribe") + client.unsubscribe(clientListener) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/IRelayClientListener.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/IRelayClientListener.kt new file mode 100644 index 0000000000..364776fe5e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/IRelayClientListener.kt @@ -0,0 +1,138 @@ +/** + * 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.quartz.nip01Core.relay.client.listeners + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient + +enum class RelayState { + // Websocket connected + CONNECTED, + + // Websocket disconnecting + DISCONNECTING, + + // Websocket disconnected + DISCONNECTED, +} + +interface IRelayClientListener { + /** + * New Event arrives from the relay. + */ + fun onEvent( + relay: IRelayClient, + subId: String, + event: Event, + arrivalTime: Long, + afterEOSE: Boolean, + ) {} + + /** + * New EOSE command arrives for a subscription + */ + fun onEOSE( + relay: IRelayClient, + subId: String, + arrivalTime: Long, + ) {} + + /** + * New error + */ + fun onError( + relay: IRelayClient, + subId: String, + error: Error, + ) {} + + /** + * Relay is requesting authentication with the given challenge. + */ + fun onAuth( + relay: IRelayClient, + challenge: String, + ) {} + + /** + * called after the relay receives the OK from an Auth message + */ + fun onAuthed( + relay: IRelayClient, + eventId: String, + success: Boolean, + message: String, + ) {} + + /** + * RelayState changes + */ + fun onRelayStateChange( + relay: IRelayClient, + type: RelayState, + ) {} + + /** + * NOTIFY command has arrived. + */ + fun onNotify( + relay: IRelayClient, + description: String, + ) {} + + /** + * Relay closed the subscription + */ + fun onClosed( + relay: IRelayClient, + subId: String, + message: String, + ) {} + + /** + * Triggers this before sending the event. + */ + fun onBeforeSend( + relay: IRelayClient, + event: Event, + ) {} + + /** + * Triggers after the event has been sent. + */ + fun onSend( + relay: IRelayClient, + msg: String, + success: Boolean, + ) {} + + /** + * Relay accepted or rejected the event + */ + fun onSendResponse( + relay: IRelayClient, + eventId: String, + success: Boolean, + message: String, + ) {} +} + +object EmptyClientListener : IRelayClientListener diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RedirectRelayClientListener.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RedirectRelayClientListener.kt new file mode 100644 index 0000000000..75c14b13c1 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RedirectRelayClientListener.kt @@ -0,0 +1,94 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.listeners + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient + +open class RedirectRelayClientListener( + val listener: IRelayClientListener, +) : IRelayClientListener { + override fun onEvent( + relay: IRelayClient, + subId: String, + event: Event, + arrivalTime: Long, + afterEOSE: Boolean, + ) = listener.onEvent(relay, subId, event, arrivalTime, afterEOSE) + + override fun onEOSE( + relay: IRelayClient, + subId: String, + arrivalTime: Long, + ) = listener.onEOSE(relay, subId, arrivalTime) + + override fun onError( + relay: IRelayClient, + subId: String, + error: Error, + ) = listener.onError(relay, subId, error) + + override fun onAuth( + relay: IRelayClient, + challenge: String, + ) = listener.onAuth(relay, challenge) + + override fun onAuthed( + relay: IRelayClient, + eventId: String, + success: Boolean, + message: String, + ) = listener.onAuthed(relay, eventId, success, message) + + override fun onRelayStateChange( + relay: IRelayClient, + type: RelayState, + ) = listener.onRelayStateChange(relay, type) + + override fun onNotify( + relay: IRelayClient, + description: String, + ) = listener.onNotify(relay, description) + + override fun onClosed( + relay: IRelayClient, + subId: String, + message: String, + ) = listener.onClosed(relay, subId, message) + + override fun onBeforeSend( + relay: IRelayClient, + event: Event, + ) = listener.onBeforeSend(relay, event) + + override fun onSend( + relay: IRelayClient, + msg: String, + success: Boolean, + ) = listener.onSend(relay, msg, success) + + override fun onSendResponse( + relay: IRelayClient, + eventId: String, + success: Boolean, + message: String, + ) = listener.onSendResponse(relay, eventId, success, message) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt new file mode 100644 index 0000000000..cef3107a30 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt @@ -0,0 +1,90 @@ +/** + * 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.quartz.nip01Core.relay.client.pool + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.TimeUtils + +class PoolEventOutbox( + val event: Event, + var relays: Set, +) { + private val tries = mutableMapOf() + + fun updateRelays(newRelays: Set) { + relays = newRelays + } + + fun isDone(url: NormalizedRelayUrl) = tries[url]?.let { it.isDone() } ?: false + + fun isDone() = relays.all { isDone(it) } + + fun relaysLeft(): Set = relays.filterTo(mutableSetOf()) { !isDone(it) } + + fun isSupposedToGo(url: NormalizedRelayUrl) = url in relays && !isDone(url) + + fun forEachUnsentEvent( + url: NormalizedRelayUrl, + run: (url: Event) -> Unit, + ) = if (isSupposedToGo(url)) run(event) else null + + fun newTry(url: NormalizedRelayUrl) { + val currentTries = tries[url] + if (currentTries != null) { + currentTries.tries.add(TimeUtils.now()) + } else { + tries.put(url, Tries(mutableListOf(TimeUtils.now()))) + } + } + + fun newResponse( + url: NormalizedRelayUrl, + success: Boolean, + message: String, + ) { + val currentTries = tries[url] + if (currentTries != null) { + currentTries.responses.add(Response(success, message)) + } else { + tries.put( + url, + Tries( + mutableListOf(TimeUtils.now() - 1), + mutableListOf(Response(success, message)), + ), + ) + } + } + + // Tries 3 times + class Tries( + val tries: MutableList = mutableListOf(), + val responses: MutableList = mutableListOf(), + ) { + fun isDone() = responses.any { it.success == true } || responses.size > 2 || tries.size > 3 + } + + class Response( + val success: Boolean, + val message: String, + ) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxRepository.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxRepository.kt new file mode 100644 index 0000000000..8ca1e03c90 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxRepository.kt @@ -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.quartz.nip01Core.relay.client.pool + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.MutableStateFlow + +class PoolEventOutboxRepository { + private var eventOutbox = mapOf() + val relays = MutableStateFlow(setOf()) + + fun updateRelays() { + val myRelays = mutableSetOf() + eventOutbox.values.forEach { + myRelays.addAll(it.relaysLeft()) + } + + if (relays.value != myRelays) { + relays.tryEmit(myRelays) + } + } + + fun markAsSending( + event: Event, + relays: Set, + ) { + val currentOutbox = eventOutbox[event.id] + if (currentOutbox == null) { + eventOutbox = eventOutbox + Pair(event.id, PoolEventOutbox(event, relays)) + } else { + currentOutbox.updateRelays(relays) + } + updateRelays() + } + + fun newTry( + id: HexKey, + url: NormalizedRelayUrl, + ) { + eventOutbox[id]?.newTry(url) + } + + fun newResponse( + id: HexKey, + url: NormalizedRelayUrl, + success: Boolean, + message: String, + ) { + val waiting = eventOutbox[id] + if (waiting != null) { + waiting.newResponse(url, success, message) + clear() + } + } + + fun clear() { + eventOutbox = eventOutbox.filter { !it.value.isDone() } + updateRelays() + } + + fun forEachUnsentEvent( + url: NormalizedRelayUrl, + run: (url: Event) -> Unit, + ) { + eventOutbox.forEach { + it.value.forEachUnsentEvent(url, run) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolSubscriptionRepository.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolSubscriptionRepository.kt new file mode 100644 index 0000000000..c24064a12b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolSubscriptionRepository.kt @@ -0,0 +1,73 @@ +/** + * 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.quartz.nip01Core.relay.client.pool + +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.LargeCache +import kotlinx.coroutines.flow.MutableStateFlow + +class PoolSubscriptionRepository { + private var subscriptions = LargeCache>>() + val relays = MutableStateFlow(setOf()) + + fun updateRelays() { + val myRelays = mutableSetOf() + subscriptions.forEach { sub, perRelayFilters -> + myRelays.addAll(perRelayFilters.keys) + } + + if (relays.value != myRelays) { + relays.tryEmit(myRelays) + } + } + + fun addOrUpdate( + subscriptionId: String, + filters: Map>, + ) { + subscriptions.put(subscriptionId, filters) + updateRelays() + } + + fun remove(subscriptionId: String) { + if (subscriptions.containsKey(subscriptionId)) { + subscriptions.remove(subscriptionId) + updateRelays() + } + } + + fun forEachSub( + relay: NormalizedRelayUrl, + run: (String, List) -> Unit, + ) { + subscriptions.forEach { subId, filters -> + val filters = filters[relay] + if (!filters.isNullOrEmpty()) { + run(subId, filters) + } else { + null + } + } + } + + fun getSubscriptionFiltersOrNull(subId: String): Map>? = subscriptions.get(subId) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayBasedFilter.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayBasedFilter.kt new file mode 100644 index 0000000000..ed0fcfa9c1 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayBasedFilter.kt @@ -0,0 +1,45 @@ +/** + * 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.quartz.nip01Core.relay.client.pool + +import android.util.Log +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Represents a filter that should only be sent to a given relay. + */ +class RelayBasedFilter( + val relay: NormalizedRelayUrl, + val filter: Filter, +) + +fun List.groupByRelay(): Map> { + val result = mutableMapOf>() + for (relayBasedFilter in this) { + if (relayBasedFilter.filter.isFilledFilter()) { + result.getOrPut(relayBasedFilter.relay) { mutableListOf() }.add(relayBasedFilter.filter) + } else { + Log.e("FilterError", "Ignoring empty filter for ${relayBasedFilter.relay}") + } + } + return result +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt new file mode 100644 index 0000000000..f745e96b5c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt @@ -0,0 +1,337 @@ +/** + * 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.quartz.nip01Core.relay.client.pool + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.EmptyClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.LargeCache +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +val UnsupportedRelayCreation: (url: NormalizedRelayUrl) -> IRelayClient = { + throw UnsupportedOperationException("Cannot create new relays") +} + +/** + * RelayPool manages a collection of Nostr relays, abstracting individual connections and providing + * unified methods for sending events, managing subscriptions, and tracking relay states. + * + * Key features: + * - Maintains a cache of relays using LargeCache for efficient relay management + * - Propagates event notifications to a shared listener across all relays + * - Provides state tracking through RelayPoolStatus (connected/available relays) + * - Supports relay lifecycle operations like connect/disconnect/reconnect + * - Maintains an immutable createNewRelay function for custom relay creation + * + * Listens to relay events and: + * 1. Forwards callbacks to parent listener + * 2. Updates statusFlow when relay connectivity or events change + * 3. Automatically reconnects relays that need reconnection + * + * Common use cases: + * - Sending events to multiple relays simultaneously (send method) + * - Managing subscriptions across the relay pool (sendRequest/closed methods) + * - Maintaining optimal relay connections (updatePool/addRelay/removeRelay methods) + */ +class RelayPool( + val listener: IRelayClientListener = EmptyClientListener, + val createNewRelay: (url: NormalizedRelayUrl) -> IRelayClient = UnsupportedRelayCreation, +) : IRelayClientListener { + private val relays = LargeCache() + + // Backing property to avoid flow emissions from other classes + private val _statusFlow = MutableStateFlow(RelayPoolStatus()) + val statusFlow: StateFlow = _statusFlow.asStateFlow() + + fun getRelay(url: NormalizedRelayUrl): IRelayClient? = relays.get(url) + + fun getAll() = statusFlow.value.connected + + fun getAllNeedsToReconnect() = relays.filter { url, relay -> relay.needsToReconnect() } + + fun reconnectsRelaysThatNeedTo() { + relays.forEach { url, relay -> + if (relay.needsToReconnect()) { + relay.disconnect() + relay.connect() + } + } + } + + fun connect() = + relays.forEach { url, relay -> + relay.connect() + } + + fun connectIfDisconnected() = + relays.forEach { url, relay -> + relay.connectAndSyncFiltersIfDisconnected() + } + + fun connectIfDisconnected(relay: NormalizedRelayUrl) = relays.get(relay)?.connectAndSyncFiltersIfDisconnected() + + fun disconnect() = + relays.forEach { url, relay -> + relay.disconnect() + } + + fun sendRequest( + relay: NormalizedRelayUrl, + subId: String, + filters: List, + ) { + relays.get(relay)?.sendRequest(subId, filters) + } + + fun sendRequest( + subId: String, + filters: Map>, + ) { + relays.forEach { url, relay -> + val filters = filters[relay.url] + if (!filters.isNullOrEmpty()) { + relay.sendRequest(subId, filters) + } + } + } + + fun sendCount( + relay: NormalizedRelayUrl, + subId: String, + filters: List, + ) { + relays.get(relay)?.sendCount(subId, filters) + } + + fun sendCount( + subId: String, + filters: Map>, + ) { + relays.forEach { url, relay -> + val filters = filters[relay.url] + if (!filters.isNullOrEmpty()) { + relay.sendCount(subId, filters) + } + } + } + + fun close(subscriptionId: String) = + relays.forEach { url, relay -> + relay.close(subscriptionId) + } + + fun close( + relay: NormalizedRelayUrl, + subscriptionId: String, + ) = relays.get(relay)?.close(subscriptionId) + + fun send( + signedEvent: Event, + list: Set, + ) { + list.forEach { + getOrCreateRelay(it).send(signedEvent) + } + } + + // -------------------- + // Pool Maintenance + // -------------------- + fun getOrCreateRelay(relay: NormalizedRelayUrl) = relays.getOrCreate(relay, createNewRelay) + + fun createRelayIfAbsent(relay: NormalizedRelayUrl): Boolean = relays.createIfAbsent(relay, createNewRelay) + + /** + * Updates the pool of relays without disconnecting the existing ones. + */ + fun updatePool(newRelays: Set) { + val toRemove = relays.keys() - newRelays + var atLeastOne = false + + newRelays.forEach { + if (createRelayIfAbsent(it)) { + atLeastOne = true + } + } + + toRemove.forEach { + if (removeRelayInner(it)) { + atLeastOne = true + } + } + + if (atLeastOne) { + updateStatus() + } + } + + fun addRelay(relay: NormalizedRelayUrl): IRelayClient { + if (createRelayIfAbsent(relay)) { + updateStatus() + } + return getOrCreateRelay(relay) + } + + fun addAllRelays(relayList: List) { + var atLeastOne = false + relayList.forEach { + if (createRelayIfAbsent(it)) { + atLeastOne = true + } + } + if (atLeastOne) { + updateStatus() + } + } + + private fun removeRelayInner(relay: NormalizedRelayUrl): Boolean { + val relayInPool = relays.remove(relay) + if (relayInPool != null) { + relayInPool.disconnect() + return true + } + return false + } + + fun removeRelay(relay: NormalizedRelayUrl) { + if (removeRelayInner(relay)) { + updateStatus() + } + } + + fun removeAllRelays() { + if (relays.size() > 0) { + disconnect() + relays.clear() + updateStatus() + } + } + + // -------------------- + // Listener Interceptor + // -------------------- + + override fun onEvent( + relay: IRelayClient, + subId: String, + event: Event, + arrivalTime: Long, + afterEOSE: Boolean, + ) { + listener.onEvent(relay, subId, event, arrivalTime, afterEOSE) + } + + override fun onError( + relay: IRelayClient, + subId: String, + error: Error, + ) { + listener.onError(relay, subId, error) + updateStatus() + } + + override fun onEOSE( + relay: IRelayClient, + subId: String, + arrivalTime: Long, + ) { + listener.onEOSE(relay, subId, arrivalTime) + } + + override fun onRelayStateChange( + relay: IRelayClient, + type: RelayState, + ) { + listener.onRelayStateChange(relay, type) + updateStatus() + } + + override fun onSendResponse( + relay: IRelayClient, + eventId: String, + success: Boolean, + message: String, + ) = listener.onSendResponse(relay, eventId, success, message) + + override fun onAuth( + relay: IRelayClient, + challenge: String, + ) = listener.onAuth(relay, challenge) + + override fun onNotify( + relay: IRelayClient, + description: String, + ) = listener.onNotify(relay, description) + + override fun onClosed( + relay: IRelayClient, + subId: String, + message: String, + ) = listener.onClosed(relay, subId, message) + + override fun onSend( + relay: IRelayClient, + msg: String, + success: Boolean, + ) = listener.onSend(relay, msg, success) + + override fun onBeforeSend( + relay: IRelayClient, + event: Event, + ) = listener.onBeforeSend(relay, event) + + // --------------- + // STATUS Reports + // --------------- + + fun availableRelays(): Set = relays.keys() + + fun connectedRelays(): Set = + relays.mapNotNullIntoSet { url, relay -> + if (relay.isConnected()) { + url + } else { + null + } + } + + private fun updateStatus() { + val connected = connectedRelays() + val available = availableRelays() + if (_statusFlow.value.connected != connected || _statusFlow.value.available != available) { + _statusFlow.tryEmit(RelayPoolStatus(connected, available)) + } + } + + @Immutable + data class RelayPoolStatus( + val connected: Set = emptySet(), + val available: Set = emptySet(), + val isConnected: Boolean = connected.isNotEmpty(), + ) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/IRelayClient.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/IRelayClient.kt new file mode 100644 index 0000000000..76b5627225 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/IRelayClient.kt @@ -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.quartz.nip01Core.relay.client.single + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent + +interface IRelayClient { + val url: NormalizedRelayUrl + + fun connect() + + fun needsToReconnect(): Boolean + + fun connectAndRunAfterSync(onConnected: () -> Unit) + + fun connectAndSyncFiltersIfDisconnected() + + fun isConnected(): Boolean + + fun sendRequest( + subId: String, + filters: List, + ) + + fun sendCount( + subId: String, + filters: List, + ) + + fun send(event: Event) + + fun sendAuth(signedEvent: RelayAuthEvent) + + fun sendEvent(event: Event) + + fun close(subscriptionId: String) + + fun disconnect() +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/Subscription.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/Subscription.kt similarity index 83% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/Subscription.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/Subscription.kt index 3f06060b2a..c2bcf2006d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/Subscription.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/Subscription.kt @@ -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,12 +18,14 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core.relay +package com.vitorpamplona.quartz.nip01Core.relay.client.single import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import java.util.UUID +import com.vitorpamplona.quartz.utils.RandomInstance + +fun newSubId() = RandomInstance.randomChars(6) class Subscription( - val id: String = UUID.randomUUID().toString().substring(0, 4), + val id: String = newSubId(), val filters: List, ) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt new file mode 100644 index 0000000000..7555915d93 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt @@ -0,0 +1,470 @@ +/** + * 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.quartz.nip01Core.relay.client.single.basic + +import android.util.Log +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient.Companion.DELAY_TO_RECONNECT_IN_MSECS +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ToClientParser +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.bytesUsedInMemory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.cancellation.CancellationException + +/** + * A base implementation for a relay client that establishes and manages a WebSocket connection to a Nostr relay. + * This class provides fundamental connection handling, message parsing, reconnection logic, and event dispatching. + * + * @property url The relay's normalized URL. + * @property socketBuilder Provides the WebSocket instance for connection. + * @property listener Interface to notify the application of relay events and errors. + * @property stats Tracks operational statistics of the relay connection. + * @property defaultOnConnect Callback executed after a successful connection, allowing subclasses to add initialization logic. + * + * Reconnection Strategy: + * - Uses exponential backoff to retry connections, starting with [DELAY_TO_RECONNECT_IN_MSECS] (500ms). + * - Doubles the delay between reconnection attempts in case of failure. + * + * Message Handling: + * - Processes relay messages (e.g., `EVENT`, `EOSE`, `OK`, `AUTH`) and delegates to appropriate callbacks. + * - Dispatches received events, notices, and subscription closures via the [listener]. + */ +open class BasicRelayClient( + override val url: NormalizedRelayUrl, + val socketBuilder: WebsocketBuilder, + val listener: IRelayClientListener, + val stats: RelayStat = RelayStat(), + val scope: CoroutineScope, + val defaultOnConnect: (BasicRelayClient) -> Unit = { }, +) : IRelayClient { + companion object { + // waits 3 minutes to reconnect once things fail + const val DELAY_TO_RECONNECT_IN_MSECS = 500L + const val EVENT_MESSAGE_PREFIX = "[\"${EventMessage.LABEL}\"" + } + + private val logTag = "Relay ${url.displayUrl()}" + + private var socket: WebSocket? = null + private var isReady: Boolean = false + private var usingCompression: Boolean = false + + private var lastConnectTentative: Long = 0L // the beginning of time. + private var delayToConnect = DELAY_TO_RECONNECT_IN_MSECS + + private var afterEOSEPerSubscription = mutableMapOf() + + private val authResponseWatcher = mutableMapOf() + private val authChallengesSent = mutableSetOf() + + private var connectingMutex = AtomicBoolean() + + private val parser = ToClientParser() + + fun isConnectionStarted(): Boolean = socket != null + + override fun isConnected(): Boolean = socket != null && isReady + + override fun needsToReconnect() = socket?.needsReconnect() ?: true + + override fun connect() = + connectAndRunOverride { + defaultOnConnect(this) + } + + override fun connectAndRunAfterSync(onConnected: () -> Unit) { + connectAndRunOverride { + defaultOnConnect(this) + onConnected() + } + } + + fun connectAndRunOverride(onConnected: () -> Unit) { + // If there is a connection, don't wait. + if (connectingMutex.getAndSet(true)) { + return + } + + try { + if (socket != null) { + connectingMutex.set(false) + return + } + + Log.d(logTag, "Connecting...") + + lastConnectTentative = TimeUtils.now() + + socket = socketBuilder.build(url, MyWebsocketListener(onConnected)) + socket?.connect() + } catch (e: Exception) { + if (e is CancellationException) throw e + + Log.w(logTag, "Crash before connecting", e) + stats.newError(e.message ?: "Error trying to connect: ${e.javaClass.simpleName}") + + markConnectionAsClosed() + } finally { + connectingMutex.set(false) + } + } + + inner class MyWebsocketListener( + val onConnected: () -> Unit, + ) : WebSocketListener { + override fun onOpen( + pingMillis: Long, + compression: Boolean, + ) { + Log.d(logTag, "OnOpen (ping: ${pingMillis}ms${if (compression) ", using compression" else ""})") + + markConnectionAsReady(pingMillis, compression) + + scope.launch(Dispatchers.Default) { + onConnected() + } + + listener.onRelayStateChange(this@BasicRelayClient, RelayState.CONNECTED) + } + + override fun onMessage(text: String) { + if (text.startsWith(EVENT_MESSAGE_PREFIX)) { + // defers the parsing of ["EVENTS" to avoid blocking the HTTP thread + scope.launch(Dispatchers.Default) { + consumeIncomingCommand(text, onConnected) + } + } else { + consumeIncomingCommand(text, onConnected) + } + } + + override fun onClosing( + code: Int, + reason: String, + ) { + Log.w(logTag, "OnClosing $code $reason") + + listener.onRelayStateChange(this@BasicRelayClient, RelayState.DISCONNECTING) + } + + override fun onClosed( + code: Int, + reason: String, + ) { + Log.w(logTag, "OnClosed $reason") + + markConnectionAsClosed() + listener.onRelayStateChange(this@BasicRelayClient, RelayState.DISCONNECTED) + } + + override fun onFailure( + t: Throwable, + code: Int?, + response: String?, + ) { + socket?.disconnect() // 1000, "Normal close" + + // checks if this is an actual failure. Closing the socket generates an onFailure as well. + if (!(socket == null && (t.message == "Socket is closed" || t.message == "Socket closed"))) { + stats.newError(response ?: t.message ?: "onFailure event from server: ${t.javaClass.simpleName}") + } + + // Failures disconnect the relay. + markConnectionAsClosed() + + Log.w(logTag, "OnFailure $code $response ${t.message} $socket") + listener.onError( + this@BasicRelayClient, + "", + Error("WebSocket Failure. Response: $code $response. Exception: ${t.message}", t), + ) + } + } + + fun consumeIncomingCommand( + text: String, + onConnected: () -> Unit, + ) { + // Log.d(logTag, "Receiving: $text") + stats.addBytesReceived(text.bytesUsedInMemory()) + + try { + when (val msg = parser.parse(text)) { + is EventMessage -> processEvent(msg) + is EoseMessage -> processEose(msg) + is NoticeMessage -> processNotice(msg) + is OkMessage -> processOk(msg, onConnected) + is AuthMessage -> processAuth(msg) + is NotifyMessage -> processNotify(msg) + is ClosedMessage -> processClosed(msg) + else -> processUnknownMessage(text) + } + } catch (e: Throwable) { + if (e is CancellationException) throw e + stats.newError("Error processing: $text") + Log.e(logTag, "Error processing: $text") + listener.onError(this@BasicRelayClient, "", Error("Error processing $text")) + } + } + + fun markConnectionAsReady( + pingInMs: Long, + usingCompression: Boolean, + ) { + this.resetEOSEStatuses() + this.isReady = true + this.usingCompression = usingCompression + + // resets any extra delays added during on offline state + this.delayToConnect = DELAY_TO_RECONNECT_IN_MSECS + + stats.pingInMs = pingInMs + } + + fun markConnectionAsClosed() { + this.socket = null + this.isReady = false + this.usingCompression = false + this.resetEOSEStatuses() + } + + private fun processEvent(msg: EventMessage) { + // Log.w(logTag, "Event ${msg.subId} ${msg.event.toJson()}") + listener.onEvent( + relay = this, + subId = msg.subId, + event = msg.event, + arrivalTime = TimeUtils.now(), + afterEOSE = afterEOSEPerSubscription[msg.subId] == true, + ) + } + + private fun processEose(msg: EoseMessage) { + // Log.w(logTag, "EOSE ${msg.subId}") + afterEOSEPerSubscription[msg.subId] = true + listener.onEOSE(this, msg.subId, TimeUtils.now()) + } + + private fun processNotice(msg: NoticeMessage) { + Log.w(logTag, "Notice ${msg.message}") + stats.newNotice(msg.message) + listener.onError(this@BasicRelayClient, msg.message, Error("Relay sent notice: $msg.message")) + } + + private fun processOk( + msg: OkMessage, + onConnected: () -> Unit, + ) { + Log.w(logTag, "OK: ${msg.eventId} ${msg.success} ${msg.message}") + + // if this is the OK of an auth event, renew all subscriptions and resend all outgoing events. + if (authResponseWatcher.containsKey(msg.eventId)) { + val wasAlreadyAuthenticated = authResponseWatcher[msg.eventId] + authResponseWatcher.put(msg.eventId, msg.success) + if (wasAlreadyAuthenticated != true && msg.success) { + onConnected() + listener.onAuthed(this@BasicRelayClient, msg.eventId, msg.success, msg.message) + } + } + + if (!msg.success) { + stats.newNotice("Rejected event ${msg.eventId}: ${msg.message}") + } + + listener.onSendResponse(this@BasicRelayClient, msg.eventId, msg.success, msg.message) + } + + private fun processAuth(msg: AuthMessage) { + // Log.d(logTag, "Auth $newMessage") + listener.onAuth(this@BasicRelayClient, msg.challenge) + } + + private fun processNotify(msg: NotifyMessage) { + // Log.w(logTag, "Notify $newMessage") + listener.onNotify(this@BasicRelayClient, msg.message) + } + + private fun processClosed(msg: ClosedMessage) { + Log.w(logTag, "Relay Closed Subscription ${msg.subscriptionId} ${msg.message}") + stats.newNotice("Subscription closed: ${msg.subscriptionId} ${msg.message}") + afterEOSEPerSubscription[msg.subscriptionId] = false + listener.onClosed(this@BasicRelayClient, msg.subscriptionId, msg.message) + } + + private fun processUnknownMessage(newMessage: String) { + stats.newError("Unsupported message: $newMessage") + Log.w(logTag, "Unsupported message: $newMessage") + listener.onError(this, "", Error("Unsupported message: $newMessage")) + } + + override fun disconnect() { + Log.d(logTag, "Disconnecting...") + lastConnectTentative = 0L // this is not an error, so prepare to reconnect as soon as requested. + delayToConnect = DELAY_TO_RECONNECT_IN_MSECS + socket?.disconnect() + socket = null + isReady = false + usingCompression = false + resetEOSEStatuses() + } + + fun resetEOSEStatuses() { + afterEOSEPerSubscription = LinkedHashMap(afterEOSEPerSubscription.size) + + authResponseWatcher.clear() + authChallengesSent.clear() + } + + override fun sendRequest( + subId: String, + filters: List, + ) { + if (isConnectionStarted()) { + if (isReady) { + if (filters.isNotEmpty()) { + afterEOSEPerSubscription[subId] = false + writeToSocket(ReqCmd.Companion.toJson(subId, filters)) + } + } + } else { + // waits 60 seconds to reconnect after disconnected. + if (TimeUtils.now() > lastConnectTentative + delayToConnect) { + delayToConnect = delayToConnect * 2 + // sends all filters after connection is successful. + connect() + } + } + } + + override fun sendCount( + subId: String, + filters: List, + ) { + if (isConnectionStarted()) { + if (isReady) { + if (filters.isNotEmpty()) { + afterEOSEPerSubscription[subId] = false + writeToSocket(CountCmd.Companion.toJson(subId, filters)) + } + } + } else { + // waits 60 seconds to reconnect after disconnected. + if (TimeUtils.now() > lastConnectTentative + delayToConnect) { + // sends all filters after connection is successful. + delayToConnect = delayToConnect * 2 + connect() + } + } + } + + override fun connectAndSyncFiltersIfDisconnected() { + if (socket == null) { + // waits 60 seconds to reconnect after disconnected. + if (TimeUtils.now() > lastConnectTentative + delayToConnect) { + delayToConnect = delayToConnect * 2 + connect() + } + } + } + + override fun send(event: Event) { + listener.onBeforeSend(this@BasicRelayClient, event) + + if (event is RelayAuthEvent) { + sendAuth(event) + } else { + sendEvent(event) + } + } + + override fun sendAuth(signedEvent: RelayAuthEvent) { + val challenge = signedEvent.challenge() + + // only send replies to new challenges to avoid infinite loop: + if (challenge != null && challenge !in authChallengesSent) { + authResponseWatcher[signedEvent.id] = false + authChallengesSent.add(challenge) + writeToSocket(AuthCmd.Companion.toJson(signedEvent)) + } + } + + override fun sendEvent(event: Event) { + if (isConnectionStarted()) { + if (isReady) { + writeToSocket(EventCmd.Companion.toJson(event)) + } + } else { + // automatically sends all filters after connection is successful. + connect() + } + } + + private fun writeToSocket(str: String) { + if (socket == null) { + listener.onError( + this@BasicRelayClient, + "", + Error("Failed to send $str. Relay is not connected."), + ) + } + socket?.let { + // Log.d(logTag, "Sending: $str") + val result = it.send(str) + listener.onSend(this@BasicRelayClient, str, result) + stats.addBytesSent(str.bytesUsedInMemory()) + } + } + + override fun close(subscriptionId: String) { + // avoids sending closes for subscriptions that were never sent to this relay. + if (afterEOSEPerSubscription.containsKey(subscriptionId)) { + writeToSocket(CloseCmd.toJson(subscriptionId)) + afterEOSEPerSubscription[subscriptionId] = false + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/simple/OutboxCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/simple/OutboxCache.kt new file mode 100644 index 0000000000..7d6d581099 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/simple/OutboxCache.kt @@ -0,0 +1,90 @@ +/** + * 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.quartz.nip01Core.relay.client.single.simple + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RedirectRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent +import com.vitorpamplona.quartz.utils.LargeCache + +class OutboxCache( + listener: IRelayClientListener, +) : RedirectRelayClientListener(listener) { + /** + * Auth procedures require us to keep track of the outgoing events + * to make sure the relay waits for the auth to finish and send them. + */ + private val outboxCache = LargeCache() + + override fun onRelayStateChange( + relay: IRelayClient, + type: RelayState, + ) { + if (type == RelayState.CONNECTED) { + outboxCache.forEach { id, event -> + relay.sendEvent(event) + } + } + + super.onRelayStateChange(relay, type) + } + + override fun onAuthed( + relay: IRelayClient, + eventId: String, + success: Boolean, + message: String, + ) { + super.onAuthed(relay, eventId, success, message) + outboxCache.forEach { id, event -> + relay.sendEvent(event) + } + } + + override fun onBeforeSend( + relay: IRelayClient, + event: Event, + ) { + if (event !is RelayAuthEvent) { + outboxCache.put(event.id, event) + } + super.onBeforeSend(relay, event) + } + + override fun onSendResponse( + relay: IRelayClient, + eventId: String, + success: Boolean, + message: String, + ) { + // remove from cache for any error that is not an auth required error. + // for auth required, we will do the auth and try to send again. + if (outboxCache.containsKey(eventId) && !message.startsWith("auth-required")) { + outboxCache.remove(eventId) + } + + super.onSendResponse(relay, eventId, success, message) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/simple/SimpleRelayClient.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/simple/SimpleRelayClient.kt new file mode 100644 index 0000000000..0619ffb41e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/simple/SimpleRelayClient.kt @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.single.simple + +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder +import kotlinx.coroutines.CoroutineScope + +/** + * This relay client saves any event that will be sent in an outbox + * waits for auth and sends it again to make sure it is delivered. + */ +class SimpleRelayClient( + url: NormalizedRelayUrl, + socketBuilder: WebsocketBuilder, + listener: IRelayClientListener, + stats: RelayStat = RelayStat(), + scopeToParseEvents: CoroutineScope, + defaultOnConnect: (BasicRelayClient) -> Unit = { }, +) : IRelayClient by BasicRelayClient( + url, + socketBuilder, + OutboxCache(listener), + stats, + scopeToParseEvents, + defaultOnConnect, + ) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/RelayStat.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt similarity index 96% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/RelayStat.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt index fa7870141d..fd266e0ffc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/RelayStat.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt @@ -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,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core.relay +package com.vitorpamplona.quartz.nip01Core.relay.client.stats import androidx.collection.LruCache import com.vitorpamplona.quartz.utils.TimeUtils diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayStats.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt similarity index 72% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayStats.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt index 9c91ea374d..b12e95e2c7 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayStats.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt @@ -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,52 +18,56 @@ * 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.quartz.nip01Core.relay.client.stats -import com.vitorpamplona.quartz.nip01Core.relay.RelayStat +import android.util.LruCache +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl object RelayStats { - private val innerCache = mutableMapOf() + private val innerCache = + object : LruCache(1000) { + override fun create(key: NormalizedRelayUrl?) = RelayStat() + } - fun get(url: String): RelayStat = innerCache.getOrPut(url) { RelayStat() } + fun get(url: NormalizedRelayUrl): RelayStat = innerCache.get(url) fun addBytesReceived( - url: String, + url: NormalizedRelayUrl, bytesUsedInMemory: Long, ) { get(url).addBytesReceived(bytesUsedInMemory) } fun addBytesSent( - url: String, + url: NormalizedRelayUrl, bytesUsedInMemory: Long, ) { get(url).addBytesSent(bytesUsedInMemory) } fun newError( - url: String, + url: NormalizedRelayUrl, error: String?, ) { get(url).newError(error) } fun newNotice( - url: String, + url: NormalizedRelayUrl, notice: String?, ) { get(url).newNotice(notice) } fun setPing( - url: String, + url: NormalizedRelayUrl, pingInMs: Long, ) { get(url).pingInMs = pingInMs } fun newSpam( - url: String, + url: NormalizedRelayUrl, explanation: String, ) { get(url).newSpam(explanation) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/Subscription.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/Subscription.kt new file mode 100644 index 0000000000..8ed3767696 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/Subscription.kt @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions + +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +data class Subscription( + val id: String = newSubId(), + val onEose: ((time: Long, relayUrl: NormalizedRelayUrl) -> Unit)? = null, +) { + private var filters: Map>? = null // Inactive when null + + fun reset() { + filters = null + } + + fun updateFilters(newFilters: Map>?) { + filters = newFilters + } + + fun filters() = filters + + fun callEose( + time: Long, + relay: NormalizedRelayUrl, + ) { + onEose?.let { it(time, relay) } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt new file mode 100644 index 0000000000..dac7e7f2d0 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt @@ -0,0 +1,140 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.LargeCache + +/** + * Manages Nostr subscriptions using a [NostrClient], allowing subscriptions to be created, modified, + * and synchronized with relay filters. Subscriptions are stored in a cache and processed through + * [updateRelays] to update relay filters dynamically. Also tracks event statistics and EOSE (End of + * Stored Events) events, and provides utility methods to interact with subscriptions like dismissal. + * + * Key responsibilities: + * 1. Maintain a cache of active [Subscription] instances. + * 2. Handle client events (onEvent, onEOSE) using [clientListener]. + * 3. Synchronize relay filters via [updateRelays] when subscriptions change. + * 4. Provide methods to create, dismiss, and inspect subscriptions. + * + * Usage: + * - Use [requestNewSubscription] to create subscriptions. + * - Modify filters on [Subscription] at will and call [updateRelays] to apply changes. + * - Update filters based on EOSE callbacks on each subscription + * - Dismiss subscriptions with [dismissSubscription]. + */ +class SubscriptionController( + val client: NostrClient, +) { + private val subscriptions = LargeCache() + private val stats = SubscriptionStats() + + private val clientListener = + object : IRelayClientListener { + override fun onEvent( + relay: IRelayClient, + subId: String, + event: Event, + arrivalTime: Long, + afterEOSE: Boolean, + ) { + if (subscriptions.containsKey(subId)) { + stats.add(subId, event.kind) + if (afterEOSE) { + subscriptions.get(subId)?.callEose(arrivalTime, relay.url) + } + } + } + + override fun onEOSE( + relay: IRelayClient, + subId: String, + arrivalTime: Long, + ) { + if (subscriptions.containsKey(subId)) { + subscriptions.get(subId)?.callEose(arrivalTime, relay.url) + } + } + } + + init { + client.subscribe(clientListener) + } + + fun destroy() { + client.unsubscribe(clientListener) + } + + fun printStats(tag: String) = stats.printCounter(tag) + + fun getSub(subId: String) = subscriptions.get(subId) + + fun requestNewSubscription( + subId: String, + onEOSE: ((Long, NormalizedRelayUrl) -> Unit)? = null, + ): Subscription = Subscription(subId, onEose = onEOSE).also { subscriptions.put(it.id, it) } + + fun dismissSubscription(subId: String) = getSub(subId)?.let { dismissSubscription(it) } + + fun dismissSubscription(subscription: Subscription) { + client.close(subscription.id) + subscription.reset() + subscriptions.remove(subscription.id) + } + + fun updateRelays() { + val currentFilters = + subscriptions.associateWith { id, sub -> + client.getSubscriptionFiltersOrNull(id) + } + + subscriptions.forEach { id, sub -> + updateRelaysIfNeeded(id, sub.filters(), currentFilters[id]) + } + } + + fun updateRelaysIfNeeded( + subId: String, + updatedFilters: Map>?, + currentFilters: Map>?, + ) { + if (currentFilters != null) { + if (updatedFilters == null) { + // was active and is not active anymore, just close. + client.close(subId) + } else { + client.sendRequest(subId, updatedFilters) + } + } else { + if (updatedFilters == null) { + // was not active and is still not active, does nothing + } else { + // was not active and becomes active, sends the entire filter. + client.sendRequest(subId, updatedFilters) + } + } + } +} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/SubscriptionManager.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionStats.kt similarity index 54% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/SubscriptionManager.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionStats.kt index a132d299f6..87d01b85eb 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/SubscriptionManager.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionStats.kt @@ -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,33 +18,41 @@ * 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.quartz.nip01Core.relay.client.subscriptions -class MutableSubscriptionManager : SubscriptionManager { - private var subscriptions = mapOf>() +import android.util.Log +import com.vitorpamplona.quartz.utils.LargeCache + +class SubscriptionStats { + data class Counter( + val subscriptionId: String, + val eventKind: Int, + ) { + var counter: Int = 0 + } + + private var eventCounter = LargeCache() + + private fun eventCounterIndex( + str1: String, + str2: Int, + ): Int = 31 * str1.hashCode() + str2.hashCode() fun add( subscriptionId: String, - filters: List = listOf(), + eventKind: Int, ) { - subscriptions = subscriptions + Pair(subscriptionId, filters) + val key = eventCounterIndex(subscriptionId, eventKind) + val stats = eventCounter.getOrCreate(key) { Counter(subscriptionId, eventKind) } + stats.counter++ } - fun remove(subscriptionId: String) { - subscriptions = subscriptions.minus(subscriptionId) + fun printCounter(tag: String) { + eventCounter.forEach { _, stats -> + Log.d( + tag, + "Received Events ${stats.subscriptionId} ${stats.eventKind}: ${stats.counter}", + ) + } } - - override fun isActive(subscriptionId: String): Boolean = subscriptions.contains(subscriptionId) - - override fun allSubscriptions(): Map> = subscriptions - - override fun getSubscriptionFilters(subId: String): List = subscriptions[subId] ?: emptyList() -} - -interface SubscriptionManager { - fun isActive(subscriptionId: String): Boolean - - fun allSubscriptions(): Map> - - fun getSubscriptionFilters(subId: String): List } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/AuthMessage.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/AuthMessage.kt index c1bff67b6c..dc0f83908d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/AuthMessage.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/AuthMessage.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/ClosedMessage.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/ClosedMessage.kt index 8cf16f8078..8dbb39194c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/ClosedMessage.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/ClosedMessage.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EoseMessage.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EoseMessage.kt index 455090480d..fd57436ea7 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EoseMessage.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EoseMessage.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EventMessage.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EventMessage.kt index f4957abd34..ee71c2381d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EventMessage.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EventMessage.kt @@ -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,7 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.commands.toClient import com.fasterxml.jackson.databind.JsonNode import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper class EventMessage( val subId: String, @@ -35,7 +35,7 @@ class EventMessage( fun parse(msgArray: JsonNode): EventMessage = EventMessage( msgArray.get(1).asText(), - EventMapper.fromJson(msgArray.get(2)), + JsonMapper.fromJson(msgArray.get(2)), ) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/Message.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/Message.kt index 64dcfcc735..4688862392 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/Message.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/Message.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/NoticeMessage.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/NoticeMessage.kt index eff9c10a5e..86e2993cf9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/NoticeMessage.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/NoticeMessage.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/NotifyMessage.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/NotifyMessage.kt index 30ace93e76..cbb42d956b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/NotifyMessage.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/NotifyMessage.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/OkMessage.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/OkMessage.kt index 492114465d..db6ed62b25 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/OkMessage.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/OkMessage.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/ToClientParser.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/ToClientParser.kt index ea654aebae..f324c42c0c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/ToClientParser.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/ToClientParser.kt @@ -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,11 +20,11 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.commands.toClient -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper class ToClientParser { fun parse(newMessage: String): Message? { - val msgArray = EventMapper.mapper.readTree(newMessage) + val msgArray = JsonMapper.mapper.readTree(newMessage) val type = msgArray.get(0).asText() return when (type) { EventMessage.LABEL -> EventMessage.parse(msgArray) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/AuthCmd.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/AuthCmd.kt index fd35bb14df..95c11e8889 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/AuthCmd.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/AuthCmd.kt @@ -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,7 +21,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay import com.fasterxml.jackson.databind.JsonNode -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent class AuthCmd( @@ -36,7 +36,7 @@ class AuthCmd( @JvmStatic fun parse(msgArray: JsonNode): AuthCmd = AuthCmd( - EventMapper.fromJson(msgArray.get(1)) as RelayAuthEvent, + JsonMapper.fromJson(msgArray.get(1)) as RelayAuthEvent, ) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CloseCmd.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CloseCmd.kt index c2f5d08ac1..0bd9d40920 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CloseCmd.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CloseCmd.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/Command.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/Command.kt index 50c4c7d8f4..2184ac62c1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/Command.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/Command.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CountCmd.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CountCmd.kt index b0ea5d1e0b..7c9c7cc4e6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CountCmd.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CountCmd.kt @@ -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,7 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.node.ObjectNode -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterDeserializer import com.vitorpamplona.quartz.utils.joinToStringLimited @@ -54,7 +54,7 @@ class CountCmd( val filters = mutableListOf() for (i in 2 until msgArray.size()) { - val json = EventMapper.mapper.readTree(msgArray.get(i).asText()) + val json = JsonMapper.mapper.readTree(msgArray.get(i).asText()) if (json is ObjectNode) { filters.add(FilterDeserializer.fromJson(json)) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/EventCmd.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/EventCmd.kt index 002e170284..ea8349a246 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/EventCmd.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/EventCmd.kt @@ -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,7 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay import com.fasterxml.jackson.databind.JsonNode import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper class EventCmd( val event: Event, @@ -36,7 +36,7 @@ class EventCmd( @JvmStatic fun parse(msgArray: JsonNode): EventCmd = EventCmd( - EventMapper.fromJson(msgArray.get(1)), + JsonMapper.fromJson(msgArray.get(1)), ) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/ReqCmd.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/ReqCmd.kt index 7d93b978f7..01439d24b6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/ReqCmd.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/ReqCmd.kt @@ -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,7 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.node.ObjectNode -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterDeserializer import com.vitorpamplona.quartz.utils.joinToStringLimited @@ -54,7 +54,7 @@ class ReqCmd( val filters = mutableListOf() for (i in 2 until msgArray.size()) { - val json = EventMapper.mapper.readTree(msgArray.get(i).asText()) + val json = JsonMapper.mapper.readTree(msgArray.get(i).asText()) if (json is ObjectNode) { filters.add(FilterDeserializer.fromJson(json)) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/ToRelayParser.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/ToRelayParser.kt index b90ad14e48..1f0e558793 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/ToRelayParser.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/ToRelayParser.kt @@ -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,11 +20,11 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper class ToRelayParser { fun parse(newMessage: String): Command? { - val msgArray = EventMapper.mapper.readTree(newMessage) + val msgArray = JsonMapper.mapper.readTree(newMessage) val type = msgArray.get(0).asText() return when (type) { AuthCmd.LABEL -> AuthCmd.parse(msgArray) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt index ec7864c089..d62ddfacef 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt @@ -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,8 +20,27 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.filters +import android.util.Log import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +/** + * A filter for Nostr events used in relay subscriptions. Supports various criteria + * to match events based on IDs, authors, kinds, tags, time ranges, search terms, and limits. + * + * Parameters: + * - ids: Optional list of event IDs to match (must be 64 characters). + * - authors: Optional list of author public keys (must be 64 characters). + * - kinds: Optional list of event kinds to include. + * - tags: Optional map of tag names to values arrays (common tags like 'p', 'e', 'a' are validated). + * - since: Optional timestamp for filtering events with publication time ≥ this value. + * - until: Optional timestamp for filtering events with publication time ≤ this value. + * - limit: Optional maximum number of events to request. + * - search: Optional string to search within event content. + * + * This class performs validation on construction to ensure all string-based identifiers + * follow Nostr requirements (64-char hex, onion addresses) and logs errors for invalid inputs. + */ class Filter( val ids: List? = null, val authors: List? = null, @@ -46,4 +65,36 @@ class Filter( limit: Int? = this.limit, search: String? = this.search, ) = Filter(ids, authors, kinds, tags, since, until, limit, search) + + /** + * Returns true if this filter contains any non-null and non-empty criteria. + */ + fun isFilledFilter() = + (ids != null && ids.isNotEmpty()) || + (authors != null && authors.isNotEmpty()) || + (kinds != null && kinds.isNotEmpty()) || + (tags != null && tags.isNotEmpty() && tags.values.all { it.isNotEmpty() }) || + (since != null) || + (until != null) || + (limit != null) || + (search != null && search.isNotEmpty()) + + init { + ids?.forEach { + if (it.length != 64) Log.e("FilterError", "Invalid id length $it on ${toJson()}") + } + authors?.forEach { + if (it.length != 64) Log.e("FilterError", "Invalid author length $it on ${toJson()}") + } + // tests common tags. + tags?.get("p")?.forEach { + if (it.length != 64) Log.e("FilterError", "Invalid p-tag length $it on ${toJson()}") + } + tags?.get("e")?.forEach { + if (it.length != 64) Log.e("FilterError", "Invalid e-tag length $it on ${toJson()}") + } + tags?.get("a")?.forEach { + if (Address.parse(it) == null) Log.e("FilterError", "Invalid a-tag $it on ${toJson()}") + } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterDeserializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterDeserializer.kt index fd5b1a1052..dc224815d5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterDeserializer.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterDeserializer.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterMatcher.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterMatcher.kt index c354ec4d48..1367984eea 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterMatcher.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterMatcher.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterSerializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterSerializer.kt index 830a0eb865..8756646402 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterSerializer.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterSerializer.kt @@ -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,7 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.filters import com.fasterxml.jackson.databind.node.JsonNodeFactory import com.fasterxml.jackson.databind.node.ObjectNode -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper object FilterSerializer { fun toJsonObject( @@ -80,7 +80,7 @@ object FilterSerializer { limit: Int? = null, search: String? = null, ): String = - EventMapper.mapper.writeValueAsString( + JsonMapper.mapper.writeValueAsString( toJsonObject( ids, authors, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt new file mode 100644 index 0000000000..d9e80aefa4 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt @@ -0,0 +1,46 @@ +/** + * 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.quartz.nip01Core.relay.normalizer + +data class NormalizedRelayUrl( + val url: String, +) : Comparable { + override fun compareTo(other: NormalizedRelayUrl) = url.compareTo(other.url) +} + +fun NormalizedRelayUrl.displayUrl() = + url + .removePrefix("wss://") + .removePrefix("ws://") + .removeSuffix("/") + +fun NormalizedRelayUrl.toHttp() = + if (url.startsWith("wss://")) { + "https${url.drop(3)}" + } else if (url.startsWith("ws://")) { + "https${url.drop(2)}" + } else { + "https://$url" + } + +fun NormalizedRelayUrl.isOnion() = url.contains(".onion/") + +fun NormalizedRelayUrl.isLocalHost() = RelayUrlNormalizer.isLocalHost(this.url) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt new file mode 100644 index 0000000000..8a74e2cdec --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt @@ -0,0 +1,197 @@ +/** + * 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.quartz.nip01Core.relay.normalizer + +import android.util.Log +import androidx.collection.LruCache +import kotlinx.coroutines.CancellationException +import org.czeal.rfc3986.URIReference +import java.lang.IllegalArgumentException +import kotlin.contracts.ExperimentalContracts + +sealed interface NormalizationResult { + class Sucess( + val url: NormalizedRelayUrl, + ) : NormalizationResult + + object Error : NormalizationResult +} + +val normalizedUrls = LruCache(5000) + +class RelayUrlNormalizer { + companion object { + fun isLocalHost(url: String) = + url.contains("127.0.0.1") || + url.contains("localhost") || + url.contains("//umbrel:") || + url.contains("192.168.") || + url.contains(".local:") || + url.contains(".local/") + + fun isOnion(url: String) = url.endsWith(".onion") || url.contains(".onion/") + + fun isRelaySchemePrefix(url: String) = url.length > 6 && url[0] == 'w' && url[1] == 's' + + fun isRelaySchemePrefixSecure(url: String) = url[2] == 's' && url[3] == ':' && url[4] == '/' && url[5] == '/' && url[6] != '/' + + fun isRelaySchemePrefixInsecure(url: String) = url[2] == ':' && url[3] == '/' && url[4] == '/' && url[5] != '/' + + fun isHttpPrefix(url: String) = url.length > 8 && url[0] == 'h' && url[1] == 't' && url[2] == 't' && url[3] == 'p' + + fun isHttpSSuffix(url: String) = url[4] == 's' && url[5] == ':' && url[6] == '/' && url[7] == '/' + + fun isHttpSuffix(url: String) = url[4] == ':' && url[5] == '/' && url[6] == '/' + + fun isRelayUrl(url: String): Boolean { + if (url.length < 3) return false + + val trimmed = + if (url[0].isWhitespace() || url[url.length - 1].isWhitespace()) { + url.trim() + } else { + url + } + + // fast + if (isRelaySchemePrefix(trimmed)) { + if (isRelaySchemePrefixSecure(trimmed)) { + return true + } else if (isRelaySchemePrefixInsecure(trimmed)) { + return true + } + } + + return false + } + + private fun norm(url: String) = + NormalizedRelayUrl( + URIReference + .parse(url) + .normalize() + .toString() + .intern(), + ) + + @OptIn(ExperimentalContracts::class) + fun fix(url: String): String? { + if (url.length < 3) return null + + val trimmed = + if (url[0].isWhitespace() || url[url.length - 1].isWhitespace()) { + url.trim() + } else { + url + } + + // fast for good wss:// urls + if (isRelaySchemePrefix(trimmed)) { + if (isRelaySchemePrefixSecure(trimmed) || isRelaySchemePrefixInsecure(trimmed)) { + return trimmed + } + } + + // fast for good https:// urls + if (isHttpPrefix(trimmed)) { + if (isHttpSSuffix(trimmed)) { + // https:// + return "wss://${trimmed.drop(8)}" + } else if (isHttpSuffix(trimmed)) { + // http:// + return "ws://${trimmed.drop(7)}" + } + } + + // fast for good ww:// urls + if (trimmed.startsWith("ww://")) { + return "wss://${trimmed.drop(5)}" + } + + // fast for good ww:// urls + if (trimmed.startsWith("was://")) { + return "wss://${trimmed.drop(6)}" + } + + // fast for good ww:// urls + if (trimmed.startsWith("Wws://")) { + return "wss://${trimmed.drop(6)}" + } + + if (trimmed.contains("://")) { + // some other scheme we cannot connect to. + Log.w("RelayUrlNormalizer", "Rejected relay URL: $url") + return null + } + + return if (isOnion(trimmed) || isLocalHost(trimmed)) { + "ws://$trimmed" + } else { + "wss://$trimmed" + } + } + + fun normalize(url: String): NormalizedRelayUrl { + normalizedUrls[url]?.let { + return when (it) { + is NormalizationResult.Sucess -> it.url + else -> throw IllegalArgumentException("Invalid Url: $url") + } + } + + return try { + val fixed = fix(url) ?: return NormalizedRelayUrl(url) + val normalized = norm(fixed) + normalizedUrls.put(url, NormalizationResult.Sucess(normalized)) + normalized + } catch (e: Exception) { + NormalizedRelayUrl(url) + } + } + + fun normalizeOrNull(url: String): NormalizedRelayUrl? { + if (url.isEmpty()) return null + normalizedUrls[url]?.let { + return when (it) { + is NormalizationResult.Sucess -> it.url + else -> null + } + } + + return try { + val fixed = fix(url) + if (fixed != null) { + val normalized = norm(fixed) + normalizedUrls.put(url, NormalizationResult.Sucess(normalized)) + return normalized + } else { + Log.w("NormalizedRelayUrl", "Rejected Error $url") + normalizedUrls.put(url, NormalizationResult.Error) + null + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("NormalizedRelayUrl", "Rejected Error $url") + null + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocket.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocket.kt index 36387e350c..090617cba9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocket.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocket.kt @@ -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,9 +21,11 @@ package com.vitorpamplona.quartz.nip01Core.relay.sockets interface WebSocket { + fun needsReconnect(): Boolean + fun connect() - fun cancel() + fun disconnect() fun send(msg: String): Boolean } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocketListener.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocketListener.kt index 8d6499d2ae..acebf8d6b7 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocketListener.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocketListener.kt @@ -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 @@ -40,6 +40,7 @@ interface WebSocketListener { fun onFailure( t: Throwable, + code: Int?, response: String?, ) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilder.kt index a5c11a1672..b2ca9aa9ac 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilder.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilder.kt @@ -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,9 +20,11 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.sockets +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + interface WebsocketBuilder { fun build( - url: String, + url: NormalizedRelayUrl, out: WebSocketListener, ): WebSocket } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilderFactory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilderFactory.kt index 5be40adee1..4aa2006120 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilderFactory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilderFactory.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/EventTemplate.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/EventTemplate.kt index 60d7527d2a..25a2196d67 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/EventTemplate.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/EventTemplate.kt @@ -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,19 +20,28 @@ */ package com.vitorpamplona.quartz.nip01Core.signers +import com.fasterxml.jackson.annotation.JsonProperty import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.builder import com.vitorpamplona.quartz.nip01Core.core.tagArray +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.utils.TimeUtils class EventTemplate( + @JsonProperty("created_at") val createdAt: Long, val kind: Int, val tags: TagArray, val content: String, -) +) { + fun toJson(): String = JsonMapper.mapper.writeValueAsString(this) + + companion object { + fun fromJson(json: String): EventTemplate = EventTemplateManualDeserializer.fromJson(JsonMapper.mapper.readTree(json)) + } +} inline fun eventTemplate( kind: Int, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/EventTemplateDeserializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/EventTemplateDeserializer.kt new file mode 100644 index 0000000000..94e69cf398 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/EventTemplateDeserializer.kt @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.signers + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.deser.std.StdDeserializer +import com.vitorpamplona.quartz.nip01Core.core.Event + +class EventTemplateDeserializer : StdDeserializer>(EventTemplate::class.java) { + override fun deserialize( + jp: JsonParser, + ctxt: DeserializationContext, + ): EventTemplate = EventTemplateManualDeserializer.fromJson(jp.codec.readTree(jp)) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/EventTemplateManualDeserializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/EventTemplateManualDeserializer.kt new file mode 100644 index 0000000000..abdded6bd9 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/EventTemplateManualDeserializer.kt @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.signers + +import com.fasterxml.jackson.databind.JsonNode +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.jackson.toTypedArray + +class EventTemplateManualDeserializer { + companion object { + fun fromJson(jsonObject: JsonNode): EventTemplate = + EventTemplate( + createdAt = jsonObject.get("created_at").asLong(), + kind = jsonObject.get("kind").asInt(), + tags = + jsonObject.get("tags").toTypedArray { + it.toTypedArray { s -> if (s.isNull) "" else s.asText().intern() } + }, + content = jsonObject.get("content").asText(), + ) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt index d5f401c9b2..40dbd3a165 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt @@ -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,10 +20,8 @@ */ package com.vitorpamplona.quartz.nip01Core.signers -import com.vitorpamplona.quartz.EventFactory import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent @@ -31,104 +29,51 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent abstract class NostrSigner( val pubKey: HexKey, ) { - fun sign( - ev: EventTemplate, - onReady: (T) -> Unit, - ) = sign(ev.createdAt, ev.kind, ev.tags, ev.content, onReady) + abstract fun isWriteable(): Boolean - abstract fun sign( + suspend fun sign(ev: EventTemplate): T = sign(ev.createdAt, ev.kind, ev.tags, ev.content) + + abstract suspend fun sign( createdAt: Long, kind: Int, tags: Array>, content: String, - onReady: (T) -> Unit, - ) + ): T - abstract fun nip04Encrypt( - decryptedContent: String, + abstract suspend fun nip04Encrypt( + plaintext: String, toPublicKey: HexKey, - onReady: (String) -> Unit, - ) + ): String - abstract fun nip04Decrypt( - encryptedContent: String, + abstract suspend fun nip04Decrypt( + ciphertext: String, fromPublicKey: HexKey, - onReady: (String) -> Unit, - ) + ): String - abstract fun nip44Encrypt( - decryptedContent: String, + abstract suspend fun nip44Encrypt( + plaintext: String, toPublicKey: HexKey, - onReady: (String) -> Unit, - ) + ): String - abstract fun nip44Decrypt( + abstract suspend fun nip44Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ): String + + abstract suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent + + abstract suspend fun deriveKey(nonce: HexKey): HexKey + + suspend fun decrypt( encryptedContent: String, fromPublicKey: HexKey, - onReady: (String) -> Unit, - ) + ): String { + if (encryptedContent.isBlank()) throw SignerExceptions.NothingToDecrypt() - abstract fun decryptZapEvent( - event: LnZapRequestEvent, - onReady: (LnZapPrivateEvent) -> Unit, - ) - - abstract fun deriveKey( - nonce: HexKey, - onReady: (HexKey) -> Unit, - ) - - fun decrypt( - encryptedContent: String, - fromPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - if (EncryptedInfo.isNIP04(encryptedContent)) { - nip04Decrypt(encryptedContent, fromPublicKey, onReady) + return if (EncryptedInfo.isNIP04(encryptedContent)) { + nip04Decrypt(encryptedContent, fromPublicKey) } else { - nip44Decrypt(encryptedContent, fromPublicKey, onReady) + nip44Decrypt(encryptedContent, fromPublicKey) } } - - fun assembleRumor( - ev: EventTemplate, - onReady: (T) -> Unit, - ) = assembleRumor(ev.createdAt, ev.kind, ev.tags, ev.content, onReady) - - fun assembleRumor( - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - onReady: (T) -> Unit, - ) { - onReady( - EventFactory.create( - id = EventHasher.hashId(pubKey, createdAt, kind, tags, content), - pubKey = pubKey, - createdAt = createdAt, - kind = kind, - tags = tags, - content = content, - sig = "", - ) as T, - ) - } - - fun assembleRumor(ev: EventTemplate) = assembleRumor(ev.createdAt, ev.kind, ev.tags, ev.content) - - fun assembleRumor( - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - ) = EventFactory.create( - id = EventHasher.hashId(pubKey, createdAt, kind, tags, content), - pubKey = pubKey, - createdAt = createdAt, - kind = kind, - tags = tags, - content = content, - sig = "", - ) as T } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerInternal.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerInternal.kt index a2d568d6e8..a9fc14f093 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerInternal.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerInternal.kt @@ -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,65 +26,76 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import kotlinx.coroutines.CancellationException class NostrSignerInternal( val keyPair: KeyPair, ) : NostrSigner(keyPair.pubKey.toHexKey()) { val signerSync = NostrSignerSync(keyPair) - override fun sign( + override fun isWriteable(): Boolean = keyPair.privKey != null + + inline fun runWrapErrors(action: () -> T): T = + try { + action() + } catch (e: Exception) { + if (e is CancellationException) throw e + if (e is SignerExceptions) throw e + throw SignerExceptions.CouldNotPerformException("Could not perform the operation", e) + } + + override suspend fun sign( createdAt: Long, kind: Int, tags: Array>, content: String, - onReady: (T) -> Unit, - ) { - signerSync.sign(createdAt, kind, tags, content)?.let { onReady(it) } - } + ): T = + runWrapErrors { + signerSync.sign(createdAt, kind, tags, content) + } - override fun nip04Encrypt( - decryptedContent: String, + override suspend fun nip04Encrypt( + plaintext: String, toPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - signerSync.nip04Encrypt(decryptedContent, toPublicKey)?.let { onReady(it) } - } + ): String = + runWrapErrors { + signerSync.nip04Encrypt(plaintext, toPublicKey) + } - override fun nip04Decrypt( - encryptedContent: String, + override suspend fun nip04Decrypt( + ciphertext: String, fromPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - signerSync.nip04Decrypt(encryptedContent, fromPublicKey)?.let { onReady(it) } - } + ): String = + runWrapErrors { + signerSync.nip04Decrypt(ciphertext, fromPublicKey) + } - override fun nip44Encrypt( - decryptedContent: String, + override suspend fun nip44Encrypt( + plaintext: String, toPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - signerSync.nip44Encrypt(decryptedContent, toPublicKey)?.let { onReady(it) } - } + ): String = + runWrapErrors { + signerSync.nip44Encrypt(plaintext, toPublicKey) + } - override fun nip44Decrypt( - encryptedContent: String, + override suspend fun nip44Decrypt( + ciphertext: String, fromPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - signerSync.nip44Decrypt(encryptedContent, fromPublicKey)?.let { onReady(it) } + ): String = + runWrapErrors { + signerSync.nip44Decrypt(ciphertext, fromPublicKey) + } + + override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent { + if (!event.isPrivateZap()) throw SignerExceptions.NothingToDecrypt() + + return runWrapErrors { + signerSync.decryptZapEvent(event) + } } - override fun decryptZapEvent( - event: LnZapRequestEvent, - onReady: (LnZapPrivateEvent) -> Unit, - ) { - signerSync.decryptZapEvent(event)?.let { onReady(it) } - } - - override fun deriveKey( - nonce: HexKey, - onReady: (HexKey) -> Unit, - ) { - signerSync.deriveKey(nonce)?.let { onReady(it) } - } + override suspend fun deriveKey(nonce: HexKey): HexKey = + runWrapErrors { + signerSync.deriveKey(nonce) + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerSync.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerSync.kt index 3376d70fcf..5969ca622a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerSync.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerSync.kt @@ -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,7 +20,6 @@ */ package com.vitorpamplona.quartz.nip01Core.signers -import android.util.Log import com.vitorpamplona.quartz.experimental.decoupling.EncryptionKeyDerivation import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -28,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.EventAssembler import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo import com.vitorpamplona.quartz.nip04Dm.crypto.Nip04 import com.vitorpamplona.quartz.nip44Encryption.Nip44 import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent @@ -45,8 +45,8 @@ class NostrSignerSync( kind: Int, tags: Array>, content: String, - ): T? { - if (keyPair.privKey == null) return null + ): T { + if (keyPair.privKey == null) throw SignerExceptions.ReadOnlyException() return if (isUnsignedPrivateZapEvent(kind, tags)) { // this is a private zap @@ -68,71 +68,80 @@ class NostrSignerSync( kind: Int, tags: Array>, content: String, - ): T? { - if (keyPair.privKey == null) return null + ): T { + if (keyPair.privKey == null) throw SignerExceptions.ReadOnlyException() return EventAssembler.hashAndSign(pubKey, createdAt, kind, tags, content, keyPair.privKey) } fun nip04Encrypt( - decryptedContent: String, + plaintext: String, toPublicKey: HexKey, - ): String? { - if (keyPair.privKey == null) return null + ): String { + if (keyPair.privKey == null) throw SignerExceptions.ReadOnlyException() + if (plaintext.isBlank()) return "" return Nip04.encrypt( - decryptedContent, + plaintext, keyPair.privKey, toPublicKey.hexToByteArray(), ) } fun nip04Decrypt( - encryptedContent: String, + ciphertext: String, fromPublicKey: HexKey, - ): String? { - if (keyPair.privKey == null) return null + ): String { + if (keyPair.privKey == null) throw SignerExceptions.ReadOnlyException() + if (ciphertext.isBlank()) throw SignerExceptions.NothingToDecrypt() - return try { - Nip04.decrypt(encryptedContent, keyPair.privKey, fromPublicKey.hexToByteArray()) - } catch (e: Exception) { - Log.w("NIP04Decrypt", "Error decrypting the message ${e.message} on $encryptedContent") - null - } + return Nip04.decrypt(ciphertext, keyPair.privKey, fromPublicKey.hexToByteArray()) } fun nip44Encrypt( - decryptedContent: String, + plaintext: String, toPublicKey: HexKey, - ): String? { - if (keyPair.privKey == null) return null + ): String { + if (keyPair.privKey == null) throw SignerExceptions.ReadOnlyException() + if (plaintext.isBlank()) return "" return Nip44 .encrypt( - decryptedContent, + plaintext, keyPair.privKey, toPublicKey.hexToByteArray(), ).encodePayload() } fun nip44Decrypt( - encryptedContent: String, + ciphertext: String, fromPublicKey: HexKey, - ): String? { - if (keyPair.privKey == null) return null + ): String { + if (keyPair.privKey == null) throw SignerExceptions.ReadOnlyException() + if (ciphertext.isBlank()) throw SignerExceptions.NothingToDecrypt() return Nip44.decrypt( - payload = encryptedContent, + payload = ciphertext, privateKey = keyPair.privKey, pubKey = fromPublicKey.hexToByteArray(), ) } - fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent? = PrivateZapRequestBuilder().decryptZapEvent(event, this) + fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = PrivateZapRequestBuilder().decryptZapEvent(event, this) - fun deriveKey(nonce: HexKey): HexKey? { - if (keyPair.privKey == null) return null + fun deriveKey(nonce: HexKey): HexKey { + if (keyPair.privKey == null) throw SignerExceptions.ReadOnlyException() return EncryptionKeyDerivation.derivePrivateKey(keyPair.privKey, nonce.hexToByteArray()).toHexKey() } + + suspend fun decrypt( + encryptedContent: String, + fromPublicKey: HexKey, + ): String = + if (EncryptedInfo.isNIP04(encryptedContent)) { + nip04Decrypt(encryptedContent, fromPublicKey) + } else { + nip44Decrypt(encryptedContent, fromPublicKey) + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/SignerExceptions.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/SignerExceptions.kt new file mode 100644 index 0000000000..317a60bcea --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/SignerExceptions.kt @@ -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.quartz.nip01Core.signers + +sealed class SignerExceptions( + msg: String, + cause: Throwable? = null, +) : Exception(msg, cause) { + class ReadOnlyException : SignerExceptions("Signer is read-only") + + class UnauthorizedDecryptionException : SignerExceptions("Couldn't not decrypt the contents of this event.") + + class NothingToDecrypt : SignerExceptions("Ciphertext is Empty") + + class AutomaticallyUnauthorizedException( + msg: String, + cause: Throwable? = null, + ) : SignerExceptions(msg) + + class ManuallyUnauthorizedException( + msg: String, + cause: Throwable? = null, + ) : SignerExceptions(msg) + + class TimedOutException( + msg: String, + cause: Throwable? = null, + ) : SignerExceptions(msg) + + class CouldNotPerformException( + msg: String, + cause: Throwable? = null, + ) : SignerExceptions(msg, cause) + + class RunningOnBackgroundWithoutAutomaticPermissionException( + msg: String, + cause: Throwable? = null, + ) : SignerExceptions(msg) + + class SignerNotFoundException( + msg: String, + cause: Throwable? = null, + ) : SignerExceptions(msg) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/caches/CacheResults.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/caches/CacheResults.kt new file mode 100644 index 0000000000..cda531884c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/caches/CacheResults.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.signers.caches + +sealed interface CacheResults { + class Success( + val value: T, + ) : CacheResults + + class DontTryAgain : CacheResults + + class NeedsForegroundActivityToTryAgain( + val after: Long, + ) : CacheResults + + class CanTryAgain( + val after: Long, + ) : CacheResults +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/caches/DecryptCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/caches/DecryptCache.kt new file mode 100644 index 0000000000..05fc16b4cf --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/caches/DecryptCache.kt @@ -0,0 +1,126 @@ +/** + * 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.quartz.nip01Core.signers.caches + +import android.util.Log +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal +import com.vitorpamplona.quartz.utils.TimeUtils + +abstract class DecryptCache( + val signer: NostrSigner, +) { + val dontTryAgain = CacheResults.DontTryAgain() + var cache: CacheResults = CacheResults.CanTryAgain(0) + + fun preload(result: T) { + cache = CacheResults.Success(result) + } + + abstract suspend fun decryptAndParse( + event: I, + signer: NostrSigner, + ): T + + private suspend fun performDecrypt(input: I): T? { + try { + val response = decryptAndParse(input, signer) + cache = CacheResults.Success(response) + return response + } catch (e: SignerExceptions.ReadOnlyException) { + // Log.w("DecryptCache", "Read only user", e) + // ciphertext is blank. Cancels everything. + cache = dontTryAgain + } catch (e: SignerExceptions.NothingToDecrypt) { + Log.w("DecryptCache", "Nothing to decrypt", e) + // ciphertext is blank. Cancels everything. + cache = dontTryAgain + } catch (e: SignerExceptions.AutomaticallyUnauthorizedException) { + Log.w("DecryptCache", "NothAutomaticallyUnauthorizedException", e) + // User has rejected this permission. Don't try again. + cache = dontTryAgain + } catch (e: SignerExceptions.ManuallyUnauthorizedException) { + Log.w("DecryptCache", "ManuallyUnauthorizedException", e) + // User has rejected this permission. Don't try again. + cache = CacheResults.CanTryAgain(TimeUtils.tenSecondsFromNow()) + } catch (e: SignerExceptions.TimedOutException) { + Log.w("DecryptCache", "TimedOutException", e) + // User has did not reply to the approval request. Ignore until later time. + cache = CacheResults.CanTryAgain(TimeUtils.tenSecondsFromNow()) + } catch (e: SignerExceptions.CouldNotPerformException) { + // Log.w("DecryptCache", "CouldNotPerformException", e) + // Decryption failed. This key might not be able to decrypt anything. Don't try again. + cache = dontTryAgain + } catch (e: SignerExceptions.SignerNotFoundException) { + Log.w("DecryptCache", "SignerNotFoundException", e) + // Signer app was deleted. Not sure what to to. It should probably log off. + cache = dontTryAgain + } catch (e: SignerExceptions.RunningOnBackgroundWithoutAutomaticPermissionException) { + Log.w("DecryptCache", "RunningOnBackgroundWithoutAutomaticPermissionException", e) + // App received a notifications, asked the signer to decrypt but the permission was not automatic. + // It needs the interface but does not have it. It needs to wait for an activity. + cache = CacheResults.NeedsForegroundActivityToTryAgain(TimeUtils.tenSecondsFromNow()) + } catch (e: com.fasterxml.jackson.core.JsonParseException) { + Log.w("DecryptCache", "JsonParseException", e) + // Decryption failed. This key might not be able to decrypt anything. Don't try again. + cache = dontTryAgain + } catch (e: IllegalStateException) { + Log.w("DecryptCache", "IllegalStateException", e) + cache = dontTryAgain + } catch (e: IllegalArgumentException) { + Log.w("DecryptCache", "IllegalArgumentException", e) + cache = dontTryAgain + } + return null + } + + fun cached(): T? { + val cachedResult = cache + return if (cachedResult is CacheResults.Success) { + cachedResult.value + } else { + null + } + } + + suspend fun decrypt(input: I): T? { + val cachedResult = cache + return when (cachedResult) { + is CacheResults.Success -> cachedResult.value + is CacheResults.DontTryAgain -> null + is CacheResults.CanTryAgain -> { + if (TimeUtils.now() > cachedResult.after) { + performDecrypt(input) + } else { + null + } + } + is CacheResults.NeedsForegroundActivityToTryAgain<*> -> { + if (TimeUtils.now() > cachedResult.after && (signer !is NostrSignerExternal || signer.hasForegroundActivity())) { + performDecrypt(input) + } else { + null + } + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/README.md b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/README.md new file mode 100644 index 0000000000..c8084f0a29 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/README.md @@ -0,0 +1,193 @@ +# Event Store Module + +This module implements an **Event Store** with nostr-native queries. + +The goal was not to make the fastest database, since there could be multiple optimizations made if +consistency can be sacrificed, but a database that will never crash and never go corrupt. + +## Responsibilities + +- **Storage & Retrieval**: + Stores Nostr events and enables retrieval using Nostr filters + +- **Replaceable Events**: + - Old versions are removed when newer versions arrive. + - Old versions are blocked if newer versions exist. + +- **Ephemeral Events** + - Ephemeral events never stored. + +- **NIP-40 Expirations** + - Manages expiration timestamps and prunes expired events. + - Blocks expired events from being reinserted + +- **NIP-09 Deletion Events** + - Deletes by event id + - Deletes by address until the `created_at` + - Blocks deleted events from being re-inserted. + +- **NIP-62 Right to Vanish** + - Supports deleting an entire user until the `created_at` for enhanced privacy + +- **NIP-50 Full Text Search**: + - Implements content indexing and full text search supporting rich queries over event content. + +- **Immutable Tables** + Triggers ensure event immutability. + +## Indexing Strategy + +The store indexes events using five dedicated tables: +- `event_headers`: stores the canonical event fields. +- `event_tags`: indexes tag values for fast filtering on tag-based queries. +- `event_fts`: for the content of full text search +- `event_expirations`: to control when expired events must be deleted. +- `event_vanish`: to control up to when vanished accounts must be blocked. + +SQL triggers ensure the **immutability of stored events**, preventing accidental or intentional +modifications. + +## Querying + +This module supports optimized query planning, producing efficient SQL for multi-filter evaluation +across fields and tags, including `limit` clauses per filter: + +For instance, the following filter: +```kotlin +store.query( + listOf( + Filter(limit = 10), + Filter(authors = listOf(hexkey), kinds = listOf(1, 1111), search = "keywords", limit = 100), + Filter(kinds = listOf(20), search = "cats", limit = 30), + ) +) +``` + +Becomes + +```sql +SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers +INNER JOIN ( + SELECT event_headers.row_id AS row_id + FROM event_headers + ORDER BY created_at DESC,id ASC + LIMIT 10 + + UNION + + SELECT event_headers.row_id AS row_id + FROM event_headers + INNER JOIN event_fts ON event_fts.event_header_row_id = event_headers.row_id + WHERE event_headers.kind IN (1, 1111) + AND event_headers.pubkey = hexkey + AND event_fts MATCH "keywords" + ORDER BY created_at DESC, id ASC + LIMIT 100 + + UNION + + SELECT event_headers.row_id AS row_id + FROM event_headers + INNER JOIN event_fts ON event_fts.event_header_row_id = event_headers.row_id + WHERE event_headers.kind = 20 + AND event_fts MATCH "cats" + ORDER BY created_at DESC,id ASC + LIMIT 30 +) AS filtered ON event_headers.row_id = filtered.row_id +ORDER BY created_at DESC,id +``` + +The union operations support complex filter lists while avoiding redundant data fetching and +duplicated outstreams. + +## How to Use + +The `EventStore` class provides a high-level interface for interacting with the event database. +It is initialized with a `SQLiteDatabase` instance, and it manages the underlying tables and query planning. + +### Initialization + +To initialize the `EventStore` in your Application class: + +```kotlin +val eventStore = EventStore(context, "dbname.db", relayUrlIdentifier) +``` + +### Querying Events + +To query events, use the `query` method with one or more `Filter` objects: + +```kotlin +val filters = listOf( + Filter(limit = 10), + Filter(authors = listOf(hexkey)) +) + +val events = eventStore.query(filters) +``` + +or to receive events as the cursor sends: + +```kotlin +val filters = listOf( + Filter(limit = 10), + Filter(authors = listOf(hexkey)) +) + +eventStore.query(filters) { event -> + // do something +} +``` + +`count` and `delete` also accept one or more filters. + +### Inserting Events + +Insert a single event using the `insert` method: + +```kotlin +eventStore.insert(event) +``` + +### Deleting Events + +Events should be deleted by adding a DeletionRequest or a VanishRequest to the db, but to manually +delete an event by ID, use the `delete` method: + +```kotlin +eventStore.delete(event.id) +``` + +### Full-Text Search + +The store supports full-text search using the `search` parameter in filters: + +```kotlin +val result = eventStore.query(Filter(search = "bitcoin", limit = 20)) +``` + +This will match any event whose content contains "bitcoin", returning the most recent 20 results. + +### Periodic cleanup of expired events. + +The store exposes a `deleteExpiredEvents` to be used in a periodic clean up procedure. Users +should use a WorkManager or a coroutine to periodically call `store.deleteExpiredEvents()`. We +recommend a 15-minute window to remove recently expired events from the database. + +Here's an example of a Worker that should be added to your application class. + +```kotlin +class ExpirationWorker(appContext: Context, workerParams: WorkerParameters) : Worker(appContext, workerParams) { + override fun doWork(): Result { + YourApplication.store.deleteExpiredEvents() + return Result.success() + } +} + +fun schedulePeriodicWork(context: Context) { + val periodicWorkRequest = + PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES).build() + + WorkManager.getInstance(context).enqueue(periodicWorkRequest) +} +``` diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt new file mode 100644 index 0000000000..b89d2a14e2 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt @@ -0,0 +1,72 @@ +/** + * 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.quartz.nip01Core.store.sqlite + +import android.database.sqlite.SQLiteDatabase + +class AddressableModule { + fun create(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE UNIQUE INDEX addressable_idx + ON event_headers (kind, pubkey, d_tag) + WHERE kind >= 30000 AND kind < 40000 + """.trimIndent(), + ) + + // Rejects old addressables + db.execSQL( + """ + CREATE TRIGGER reject_older_addressable_event + BEFORE INSERT ON event_headers + FOR EACH ROW + WHEN (NEW.kind >= 30000 AND NEW.kind < 40000) + BEGIN + -- Check for existing newer record + SELECT RAISE(ABORT, 'duplicate: A newer or equally new record already exists') + WHERE EXISTS ( + SELECT 1 FROM event_headers + WHERE + event_headers.created_at >= NEW.created_at AND + event_headers.kind = NEW.kind AND + event_headers.pubkey = NEW.pubkey AND + event_headers.d_tag = NEW.d_tag + ); + + DELETE FROM event_tags + WHERE event_header_row_id in ( + SELECT row_id FROM event_headers + WHERE + event_headers.kind = NEW.kind AND + event_headers.pubkey = NEW.pubkey AND + event_headers.d_tag = NEW.d_tag + ); + + DELETE FROM event_headers + WHERE + event_headers.kind = NEW.kind AND + event_headers.pubkey = NEW.pubkey AND + event_headers.d_tag = NEW.d_tag; + END; + """.trimIndent(), + ) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt new file mode 100644 index 0000000000..3a20e47cae --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt @@ -0,0 +1,98 @@ +/** + * 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.quartz.nip01Core.store.sqlite + +import android.database.sqlite.SQLiteDatabase +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent + +class DeletionRequestModule { + fun create(db: SQLiteDatabase) { + // rejects deleted events. + db.execSQL( + """ + CREATE TRIGGER reject_deleted_events + BEFORE INSERT ON event_headers + FOR EACH ROW + BEGIN + -- Check for ID-based deletion record + SELECT RAISE(ABORT, 'blocked: a deletion event for this event id exists') + WHERE EXISTS ( + SELECT 1 FROM event_headers INNER JOIN event_tags ON event_headers.row_id = event_tags.event_header_row_id + WHERE + event_headers.created_at >= NEW.created_at AND + event_headers.kind = 5 AND + event_headers.pubkey = NEW.pubkey AND + event_tags.tag_name = 'e' AND + event_tags.tag_value = NEW.id + ); + + -- Check for address-based deletion record + SELECT RAISE(ABORT, 'blocked: a deletion event for this address exists') + WHERE EXISTS ( + SELECT 1 FROM event_headers INNER JOIN event_tags ON event_headers.row_id = event_tags.event_header_row_id + WHERE + event_headers.created_at >= NEW.created_at AND + event_headers.kind = 5 AND + event_headers.pubkey = NEW.pubkey AND + event_tags.tag_name = 'a' AND + event_tags.tag_value = NEW.kind || ':' || NEW.pubkey || ':' || NEW.d_tag + ); + END; + """.trimIndent(), + ) + } + + fun insert( + event: Event, + headerId: Long, + db: SQLiteDatabase, + ) { + if (event is DeletionEvent) { + val idValues = event.deleteEventIds() + val idParams = idValues.joinToString(",") { "?" } + + val addresses = event.deleteAddresses() + val addressParams = addresses.joinToString(",") { "(?, ?)" } + val addressValues = addresses.flatMap { listOf(it.kind, it.dTag) } + + val whereClause = + if (idValues.isNotEmpty() && addresses.isNotEmpty()) { + "(id IN ($idParams) OR (kind, d_tag) IN ($addressParams)) AND pubkey = ?" + } else if (idValues.isNotEmpty()) { + "id IN ($idParams) AND pubkey = ?" + } else if (addresses.isNotEmpty()) { + "(kind, d_tag) IN ($addressParams) AND pubkey = ?" + } else { + return + } + val whereParams = idValues.plus(addressValues).plus(event.pubKey).toTypedArray() + + db.execSQL( + """ + DELETE FROM event_headers + WHERE $whereClause; + """.trimIndent(), + whereParams, + ) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EphemeralModule.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EphemeralModule.kt new file mode 100644 index 0000000000..011f114e01 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EphemeralModule.kt @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import android.database.sqlite.SQLiteDatabase + +class EphemeralModule { + fun create(db: SQLiteDatabase) { + // Rejects all ephemeral events. + db.execSQL( + """ + CREATE TRIGGER reject_ephemeral_events + BEFORE INSERT ON event_headers + FOR EACH ROW + WHEN (NEW.kind >= 20000 AND NEW.kind < 30000) + BEGIN + SELECT RAISE(ABORT, 'blocked: cannot store ephemeral events'); + END; + """.trimIndent(), + ) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt new file mode 100644 index 0000000000..d1a2e2436e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt @@ -0,0 +1,469 @@ +/** + * 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.quartz.nip01Core.store.sqlite + +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.quartz.EventFactory +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.store.sqlite.sql.where + +class EventIndexesModule( + val fts: FullTextSearchModule, +) { + fun create(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE event_headers ( + row_id INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL, + pubkey TEXT NOT NULL, + created_at INTEGER NOT NULL, + kind INTEGER NOT NULL, + d_tag TEXT, + tags TEXT NOT NULL, + content TEXT NOT NULL, + sig TEXT NOT NULL + ) + """.trimIndent(), + ) + + db.execSQL( + """ + CREATE TABLE event_tags ( + event_header_row_id INTEGER, + tag_name TEXT NOT NULL, + tag_value TEXT NOT NULL, + FOREIGN KEY (event_header_row_id) REFERENCES event_headers(row_id) ON DELETE CASCADE + ) + """.trimIndent(), + ) + + db.execSQL("CREATE UNIQUE INDEX event_headers_id ON event_headers (id)") + db.execSQL("CREATE INDEX query_by_kind_pubkey_idx ON event_headers (created_at desc, kind, pubkey, d_tag)") + db.execSQL("CREATE INDEX query_by_id_idx ON event_headers (created_at desc, id)") + db.execSQL("CREATE INDEX query_by_tags_idx ON event_tags (tag_name, tag_value)") + + // Prevent updates to maintain immutability + db.execSQL( + """ + CREATE TRIGGER event_headers_prevent_update + BEFORE UPDATE ON event_headers + FOR EACH ROW + BEGIN + SELECT RAISE(ABORT, 'Error: Updates are not allowed.'); + END; + """.trimIndent(), + ) + + db.execSQL( + """ + CREATE TRIGGER event_tags_prevent_update + BEFORE UPDATE ON event_tags + FOR EACH ROW + BEGIN + SELECT RAISE(ABORT, 'Error: Updates are not allowed.'); + END; + """.trimIndent(), + ) + } + + val sqlInsertHeader = + """ + INSERT INTO event_headers + (id, pubkey, created_at, kind, tags, content, sig, d_tag) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent() + + val sqlInsertTags = + """ + INSERT OR ROLLBACK INTO event_tags + (event_header_row_id, tag_name, tag_value) + VALUES + (?,?,?) + """.trimIndent() + + fun insert( + event: Event, + db: SQLiteDatabase, + ): Long { + val stmt = db.compileStatement(sqlInsertHeader) + stmt.bindString(1, event.id) + stmt.bindString(2, event.pubKey) + stmt.bindLong(3, event.createdAt) + stmt.bindLong(4, event.kind.toLong()) + stmt.bindString(5, JsonMapper.mapper.writeValueAsString(event.tags)) + stmt.bindString(6, event.content) + stmt.bindString(7, event.sig) + if (event is AddressableEvent) { + stmt.bindString(8, event.dTag()) + } else { + stmt.bindNull(8) + } + val headerId = stmt.executeInsert() + + val tagsToIndex = event.indexableTags() + + if (tagsToIndex.isNotEmpty()) { + val sql = + buildString { + append(sqlInsertTags) + repeat(tagsToIndex.size - 1) { + append(",(?,?,?)") + } + } + + val stmtTags = db.compileStatement(sql) + + var index = 1 + tagsToIndex.forEach { tag -> + stmtTags.bindLong(index++, headerId) + stmtTags.bindString(index++, tag[0]) + stmtTags.bindString(index++, tag[1]) + } + + stmtTags.executeInsert() + } + + return headerId + } + + fun Event.indexableTags(): List { + val indexableTagNames = extraIndexableTagNames() + return if (indexableTagNames.isNotEmpty()) { + tags.filter { it.size >= 2 && (it[0].length == 1 || it[0] in indexableTagNames) } + } else { + tags.filter { it.size >= 2 && it[0].length == 1 } + } + } + + fun planQuery(filter: Filter): String { + val rowIdSubQuery = prepareRowIDSubQueries(filter) ?: return makeEverythingQuery() + + return makeQueryIn(rowIdSubQuery.sql) + } + + fun query( + filter: Filter, + db: SQLiteDatabase, + ): List { + val rowIdSubQuery = prepareRowIDSubQueries(filter) ?: return db.runQuery(makeEverythingQuery()) + + return db.runQuery(makeQueryIn(rowIdSubQuery.sql), rowIdSubQuery.args) + } + + fun query( + filter: Filter, + db: SQLiteDatabase, + onEach: (Event) -> Unit, + ) { + val rowIdSubQuery = prepareRowIDSubQueries(filter) ?: return db.runQueryEmitting(makeEverythingQuery(), onEach = onEach) + + db.runQueryEmitting(makeQueryIn(rowIdSubQuery.sql), rowIdSubQuery.args, onEach) + } + + fun planQuery(filters: List): String { + val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it) } + if (rowIdSubQueries.isEmpty()) return makeEverythingQuery() + val unions = rowIdSubQueries.joinToString(" UNION ") { it.sql } + return makeQueryIn(unions) + } + + fun query( + filters: List, + db: SQLiteDatabase, + ): List { + val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it) } + + if (rowIdSubQueries.isEmpty()) return db.runQuery(makeEverythingQuery()) + + val unions = rowIdSubQueries.joinToString(" UNION ") { it.sql } + val args = rowIdSubQueries.flatMap { it.args } + + return db.runQuery(makeQueryIn(unions), args) + } + + fun query( + filters: List, + db: SQLiteDatabase, + onEach: (Event) -> Unit, + ) { + val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it) } + + if (rowIdSubQueries.isEmpty()) return db.runQueryEmitting(makeEverythingQuery(), onEach = onEach) + + val unions = rowIdSubQueries.joinToString(" UNION ") { it.sql } + val args = rowIdSubQueries.flatMap { it.args } + + db.runQueryEmitting(makeQueryIn(unions), args, onEach) + } + + private fun makeEverythingQuery() = "SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY created_at DESC, id" + + private fun makeQueryIn(rowIdQuery: String) = + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ($rowIdQuery) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + """.trimIndent() + + private fun SQLiteDatabase.runQuery( + sql: String, + args: List = emptyList(), + ): List = + rawQuery(sql, args.toTypedArray()).use { cursor -> + parseResults(cursor) + } + + private fun parseResults(cursor: Cursor): List { + val events = ArrayList() + + while (cursor.moveToNext()) { + events.add( + EventFactory.create( + cursor.getString(0).intern(), + cursor.getString(1).intern(), + cursor.getLong(2), + cursor.getInt(3), + JsonMapper.mapper.readValue>>(cursor.getString(4)), + cursor.getString(5).intern(), + cursor.getString(6).intern(), + ), + ) + } + + return events + } + + private fun SQLiteDatabase.runQueryEmitting( + sql: String, + args: List = emptyList(), + onEach: (Event) -> Unit, + ) = rawQuery(sql, args.toTypedArray()).use { cursor -> + emitResults(cursor, onEach) + } + + private fun emitResults( + cursor: Cursor, + onEach: (Event) -> Unit, + ) { + while (cursor.moveToNext()) { + onEach( + EventFactory.create( + cursor.getString(0).intern(), + cursor.getString(1).intern(), + cursor.getLong(2), + cursor.getInt(3), + JsonMapper.mapper.readValue>>(cursor.getString(4)), + cursor.getString(5).intern(), + cursor.getString(6).intern(), + ), + ) + } + } + + // -------------- + // Counts + // ------------- + fun count( + filter: Filter, + db: SQLiteDatabase, + ): Int { + val rowIdSubQuery = prepareRowIDSubQueries(filter) ?: return db.countEverything() + + return db.countIn(rowIdSubQuery.sql, rowIdSubQuery.args) + } + + fun count( + filters: List, + db: SQLiteDatabase, + ): Int { + val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it) } + + if (rowIdSubQueries.isEmpty()) return db.countEverything() + + val unions = rowIdSubQueries.joinToString(" UNION ") { it.sql } + val args = rowIdSubQueries.flatMap { it.args } + + return db.countIn(unions, args) + } + + private fun SQLiteDatabase.countEverything() = runCount("SELECT count(*) as count FROM event_headers") + + private fun SQLiteDatabase.countIn( + rowIdQuery: String, + args: List, + ) = runCount("SELECT COUNT(*) as count FROM ($rowIdQuery)", args) + + private fun SQLiteDatabase.runCount( + sql: String, + args: List = emptyList(), + ): Int = + rawQuery(sql, args.toTypedArray()).use { cursor -> + cursor.moveToNext() + cursor.getInt(0) + } + + // -------------- + // Deletes + // ------------- + fun delete( + filter: Filter, + db: SQLiteDatabase, + ): Int? { + val rowIdQuery = prepareRowIDSubQueries(filter) ?: return null + return db.runDelete(rowIdQuery.sql, rowIdQuery.args) + } + + fun delete( + filters: List, + db: SQLiteDatabase, + ): Int? { + val rowIdSubqueries = filters.mapNotNull { prepareRowIDSubQueries(it) } + + if (rowIdSubqueries.isEmpty()) return null + + val unions = rowIdSubqueries.joinToString(" UNION ") { it.sql } + val args = rowIdSubqueries.flatMap { it.args } + + return db.runDelete(unions, args) + } + + private fun SQLiteDatabase.runDelete( + sql: String, + args: List = emptyList(), + ): Int = delete("event_headers", "row_id IN ($sql)", args.toTypedArray()) + + // ---------------------------- + // Inner row id selections + // ---------------------------- + fun prepareRowIDSubQueries(filter: Filter): RowIdSubQuery? { + if (!filter.isFilledFilter()) return null + + val hasHeaders = + with(filter) { + (ids != null && ids.isNotEmpty()) || + (authors != null && authors.isNotEmpty()) || + (kinds != null && kinds.isNotEmpty()) || + (since != null) || + (until != null) || + (tags != null && tags.containsKey("d")) + } + + val hasSearch = (filter.search != null && filter.search.isNotBlank()) + + val projection = + buildString { + val anchorColumn: String + val joins = mutableListOf() + + if (hasHeaders) { + append("SELECT event_headers.row_id as row_id FROM event_headers") + anchorColumn = "event_headers.row_id" + + if (hasSearch) { + joins.add("INNER JOIN ${fts.tableName} ON ${fts.tableName}.${fts.eventHeaderRowIdName} = $anchorColumn") + } + + filter.tags?.forEach { (tagName, _) -> + if (tagName != "d") { + joins.add("INNER JOIN event_tags as tag$tagName ON tag$tagName.event_header_row_id = $anchorColumn") + } + } + } else if (hasSearch) { + append("SELECT ${fts.tableName}.${fts.eventHeaderRowIdName} as row_id FROM ${fts.tableName}") + anchorColumn = "${fts.tableName}.${fts.eventHeaderRowIdName}" + + filter.tags?.forEach { (tagName, _) -> + if (tagName != "d") { + joins.add("INNER JOIN event_tags as tag$tagName ON tag$tagName.event_header_row_id = $anchorColumn") + } + } + } else { + // has only tags + filter.tags?.forEach { (tagName, _) -> + if (tagName != "d") { + if (isEmpty()) { + append("SELECT tag$tagName.event_header_row_id as row_id FROM event_tags as tag$tagName") + } else { + joins.add("INNER JOIN event_tags as tag$tagName ON tag$tagName.event_header_row_id = tag${tagName.takeLast(1)}.event_header_row_id") + } + } + } + + if (isEmpty()) { + // only limit is present + append("SELECT event_headers.row_id as row_id FROM event_headers") + } + } + + if (joins.isNotEmpty()) { + append(" ${joins.joinToString(" ")}") + } + } + + val clause = + where { + filter.ids?.let { equalsOrIn("event_headers.id", it) } + filter.kinds?.let { equalsOrIn("event_headers.kind", it) } + filter.authors?.let { equalsOrIn("event_headers.pubkey", it) } + filter.since?.let { greaterThanOrEquals("event_headers.created_at", it) } + filter.until?.let { lessThanOrEquals("event_headers.created_at", it) } + + filter.tags?.forEach { (tagName, tagValues) -> + if (tagName == "d") { + equalsOrIn("event_headers.d_tag", tagValues) + } else { + equals("tag$tagName.tag_name", tagName) + equalsOrIn("tag$tagName.tag_value", tagValues) + } + } + + filter.search?.let { match(fts.tableName, it) } + } + + val whereClause = + if (filter.limit != null) { + "${clause.conditions} ORDER BY created_at DESC, id ASC LIMIT ${filter.limit}" + } else { + clause.conditions + } + + return RowIdSubQuery("$projection WHERE $whereClause", clause.args) + } + + fun deleteAll(db: SQLiteDatabase) { + db.execSQL("DELETE FROM event_tags") + db.execSQL("DELETE FROM event_headers") + } + + class RowIdSubQuery( + val sql: String, + val args: List, + ) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt new file mode 100644 index 0000000000..4794e4c7b4 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt @@ -0,0 +1,61 @@ +/** + * 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.quartz.nip01Core.store.sqlite + +import android.content.Context +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter + +class EventStore( + context: Context, + dbName: String? = "events.db", + val relayUrl: String? = "wss://quartz.local", +) { + val store = SQLiteEventStore(context, dbName, relayUrl) + + fun insert(event: Event) = store.insertEvent(event) + + fun query(filter: Filter) = store.query(filter) + + fun query(filters: List) = store.query(filters) + + fun query( + filter: Filter, + onEach: (Event) -> Unit, + ) = store.query(filter, onEach) + + fun query( + filters: List, + onEach: (Event) -> Unit, + ) = store.query(filters, onEach) + + fun count(filter: Filter) = store.count(filter) + + fun count(filters: List) = store.count(filters) + + fun delete(filter: Filter) = store.delete(filter) + + fun delete(filters: List) = store.delete(filters) + + fun deleteExpiredEvents() = store.deleteExpiredEvents() + + fun close() = store.close() +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt new file mode 100644 index 0000000000..39f738712a --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt @@ -0,0 +1,92 @@ +/** + * 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.quartz.nip01Core.store.sqlite + +import android.database.sqlite.SQLiteDatabase +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip40Expiration.expiration + +class ExpirationModule { + fun create(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE event_expirations ( + event_header_row_id INTEGER, + expiration INTEGER NOT NULL, + FOREIGN KEY (event_header_row_id) REFERENCES event_headers(row_id) ON DELETE CASCADE + ) + """.trimIndent(), + ) + + // Rejects old addressables + db.execSQL( + """ + CREATE TRIGGER reject_expired_events + BEFORE INSERT ON event_expirations + FOR EACH ROW + BEGIN + -- Check for existing newer record + SELECT RAISE(ABORT, 'blocked: this event is expired') + WHERE NEW.expiration <= unixepoch(); + END; + """.trimIndent(), + ) + + db.execSQL("CREATE UNIQUE INDEX events_exp_id ON event_expirations (event_header_row_id)") + } + + val insertExpiration = + """ + INSERT OR ROLLBACK INTO event_expirations (event_header_row_id, expiration) + VALUES (?, ?) + """.trimIndent() + + fun insert( + event: Event, + headerId: Long, + db: SQLiteDatabase, + ) { + val exp = event.expiration() + if (exp != null && exp > 0) { + val stmt = StatementCache.get(insertExpiration, db) + stmt.bindLong(1, headerId) + stmt.bindLong(2, exp) + stmt.executeInsert() + } + } + + val deleteExpiredEvents = + """ + DELETE FROM event_headers + WHERE row_id IN ( + SELECT event_expirations.event_header_row_id FROM event_expirations + WHERE event_expirations.expiration < unixepoch() + ); + """.trimIndent() + + fun deleteExpiredEvents(db: SQLiteDatabase) { + StatementCache.get(deleteExpiredEvents, db).execute() + } + + fun deleteAll(db: SQLiteDatabase) { + db.execSQL("DELETE FROM event_expirations") + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt new file mode 100644 index 0000000000..855d9cb99a --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt @@ -0,0 +1,92 @@ +/** + * 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.quartz.nip01Core.store.sqlite + +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteException +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip50Search.SearchableEvent + +class FullTextSearchModule { + val tableName = "event_fts" + val eventHeaderRowIdName = "event_header_row_id" + val contentName = "content" + + fun create(db: SQLiteDatabase) { + val ftsVersion = FullTextSearchModule().versionFinder(db) + db.execSQL( + """ + CREATE VIRTUAL TABLE $tableName + USING fts$ftsVersion($eventHeaderRowIdName, $contentName) + """, + ) + + // Foreign key cleanup for full text search + db.execSQL( + """ + CREATE TRIGGER fts_foreign_key + AFTER DELETE ON event_headers + FOR EACH ROW + BEGIN + DELETE FROM $tableName + WHERE old.row_id = $tableName.$eventHeaderRowIdName; + END; + """, + ) + } + + val insertFTS = + """ + INSERT OR ROLLBACK INTO $tableName ($eventHeaderRowIdName, $contentName) + VALUES (?, ?) + """.trimIndent() + + fun insert( + event: Event, + headerId: Long, + db: SQLiteDatabase, + ) { + if (event is SearchableEvent) { + val stmt = StatementCache.get(insertFTS, db) + stmt.bindLong(1, headerId) + stmt.bindString(2, event.indexableContent()) + stmt.executeInsert() + } + } + + fun versionFinder(db: SQLiteDatabase): Int = + try { + try { + db.execSQL("CREATE VIRTUAL TABLE dummy_fts5 USING fts5(dummy)") + 5 + } catch (e: SQLiteException) { + db.execSQL("CREATE VIRTUAL TABLE dummy_fts4 USING fts4(dummy)") + 4 + } + } catch (e: SQLiteException) { + db.execSQL("CREATE VIRTUAL TABLE dummy_fts3 USING fts3(dummy)") + 3 + } + + fun deleteAll(db: SQLiteDatabase) { + db.execSQL("DELETE FROM event_fts") + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt new file mode 100644 index 0000000000..688aff89d2 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt @@ -0,0 +1,70 @@ +/** + * 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.quartz.nip01Core.store.sqlite + +import android.database.sqlite.SQLiteDatabase + +class ReplaceableModule { + fun create(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE UNIQUE INDEX replaceable_idx + ON event_headers (kind, pubkey) + WHERE (kind >= 10000 AND kind < 20000) OR (kind IN (0, 3)) + """.trimIndent(), + ) + + // Rejects old replaceables + db.execSQL( + """ + CREATE TRIGGER reject_older_replaceable_event + BEFORE INSERT ON event_headers + FOR EACH ROW + WHEN (NEW.kind >= 10000 AND NEW.kind < 20000) OR (NEW.kind IN (0, 3)) + BEGIN + -- Check for existing newer record + SELECT RAISE(ABORT, 'duplicate: A newer or equally new record already exists') + WHERE EXISTS ( + SELECT 1 FROM event_headers + WHERE + event_headers.created_at >= NEW.created_at AND + event_headers.kind = NEW.kind AND + event_headers.pubkey = NEW.pubkey + ); + + DELETE FROM event_tags + WHERE event_header_row_id in ( + SELECT row_id FROM event_headers + WHERE + event_headers.kind = NEW.kind AND + event_headers.pubkey = NEW.pubkey + ); + + -- Delete older records if this is the newest + DELETE FROM event_headers + WHERE + event_headers.kind = NEW.kind AND + event_headers.pubkey = NEW.pubkey; + END; + """.trimIndent(), + ) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt new file mode 100644 index 0000000000..715caeaba7 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt @@ -0,0 +1,111 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import android.database.sqlite.SQLiteDatabase +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent + +class RightToVanishModule { + fun create(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE event_vanish ( + event_header_row_id INTEGER, + pubkey TEXT NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY (event_header_row_id) REFERENCES event_headers(row_id) ON DELETE CASCADE + ) + """.trimIndent(), + ) + + db.execSQL("CREATE UNIQUE INDEX event_vanish_key ON event_vanish (pubkey)") + + db.execSQL( + """ + CREATE TRIGGER delete_older_event_vanish + BEFORE INSERT ON event_vanish + FOR EACH ROW + BEGIN + -- Delete older records if this is the newest + DELETE FROM event_vanish + WHERE + event_vanish.created_at < NEW.created_at AND + event_vanish.pubkey = NEW.pubkey; + END; + """.trimIndent(), + ) + + db.execSQL( + """ + CREATE TRIGGER delete_events_on_event_vanish + AFTER INSERT ON event_vanish + FOR EACH ROW + BEGIN + DELETE FROM event_headers WHERE created_at < NEW.created_at AND pubkey = NEW.pubkey; + END; + """.trimIndent(), + ) + + // reject new events inside a right to vanish request + db.execSQL( + """ + CREATE TRIGGER reject_events_on_event_vanish + BEFORE INSERT ON event_headers + FOR EACH ROW + BEGIN + SELECT RAISE(ABORT, 'blocked: a request to vanish event exists') + WHERE EXISTS ( + SELECT 1 FROM event_vanish + WHERE + event_vanish.created_at >= NEW.created_at AND + event_vanish.pubkey = NEW.pubkey + ); + END; + """.trimIndent(), + ) + } + + val insertRTV = + """ + INSERT OR ROLLBACK INTO event_vanish (event_header_row_id, pubkey, created_at) + VALUES (?, ?, ?) + """.trimIndent() + + fun insert( + event: Event, + relayUrl: String?, + headerId: Long, + db: SQLiteDatabase, + ) { + if (event is RequestToVanishEvent && event.shouldVanishFrom(relayUrl)) { + val stmt = StatementCache.get(insertRTV, db) + stmt.bindLong(1, headerId) + stmt.bindString(2, event.pubKey) + stmt.bindLong(3, event.createdAt) + stmt.executeInsert() + } + } + + fun deleteAll(db: SQLiteDatabase) { + db.execSQL("DELETE FROM event_vanish") + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt new file mode 100644 index 0000000000..3d80a48988 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -0,0 +1,134 @@ +/** + * 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.quartz.nip01Core.store.sqlite + +import android.content.Context +import android.database.sqlite.SQLiteConstraintException +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper +import androidx.core.database.sqlite.transaction +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.isEphemeral +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip40Expiration.isExpired + +class SQLiteEventStore( + context: Context, + dbName: String? = "events.db", + val relayUrl: String? = null, +) : SQLiteOpenHelper(context, dbName, null, DATABASE_VERSION) { + companion object { + const val DATABASE_VERSION = 1 + } + + val fullTextSearchModule = FullTextSearchModule() + val eventIndexModule = EventIndexesModule(fullTextSearchModule) + + val replaceableModule = ReplaceableModule() + val addressableModule = AddressableModule() + val ephemeralModule = EphemeralModule() + + val deletionModule = DeletionRequestModule() + val expirationModule = ExpirationModule() + val rightToVanishModule = RightToVanishModule() + + override fun onConfigure(db: SQLiteDatabase) { + super.onConfigure(db) + + // makes sure the FKs are sane + db.setForeignKeyConstraintsEnabled(true) + + // SQLite implements mutations by appending them to a log, which it occasionally + // compacts into the database. This is called Write-Ahead Logging (WAL) + db.enableWriteAheadLogging() + + // The DB can be corrupted if the OS is shutdown before sync, which generally + // doesn't happen on Android + db.execSQL("PRAGMA synchronous = OFF") + } + + override fun onCreate(db: SQLiteDatabase) { + eventIndexModule.create(db) + replaceableModule.create(db) + addressableModule.create(db) + ephemeralModule.create(db) + deletionModule.create(db) + expirationModule.create(db) + rightToVanishModule.create(db) + fullTextSearchModule.create(db) + } + + override fun onUpgrade( + db: SQLiteDatabase, + oldVersion: Int, + newVersion: Int, + ) {} + + fun clearDB() { + val db = writableDatabase + fullTextSearchModule.deleteAll(db) + rightToVanishModule.deleteAll(db) + expirationModule.deleteAll(db) + eventIndexModule.deleteAll(db) + } + + fun insertEvent(event: Event): Boolean { + if (event.isExpired()) throw SQLiteConstraintException("blocked: Cannot insert an expired event") + if (event.kind.isEphemeral()) return false + + val db = writableDatabase + db.transaction { + val headerId = eventIndexModule.insert(event, db) + deletionModule.insert(event, headerId, db) + expirationModule.insert(event, headerId, db) + fullTextSearchModule.insert(event, headerId, db) + rightToVanishModule.insert(event, relayUrl, headerId, db) + } + return true + } + + fun query(filter: Filter): List = eventIndexModule.query(filter, readableDatabase) + + fun query(filters: List): List = eventIndexModule.query(filters, readableDatabase) + + fun query( + filter: Filter, + onEach: (Event) -> Unit, + ) = eventIndexModule.query(filter, readableDatabase, onEach) + + fun query( + filters: List, + onEach: (Event) -> Unit, + ) = eventIndexModule.query(filters, readableDatabase, onEach) + + fun count(filter: Filter): Int = eventIndexModule.count(filter, readableDatabase) + + fun count(filters: List): Int = eventIndexModule.count(filters, readableDatabase) + + fun delete(filter: Filter): Int? = eventIndexModule.delete(filter, writableDatabase) + + fun delete(filters: List): Int? = eventIndexModule.delete(filters, writableDatabase) + + fun delete(id: HexKey): Int = writableDatabase.delete("event_headers", "id = ?", arrayOf(id)) + + fun deleteExpiredEvents() = expirationModule.deleteExpiredEvents(writableDatabase) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/StatementCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/StatementCache.kt new file mode 100644 index 0000000000..8d30784532 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/StatementCache.kt @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteStatement +import android.util.LruCache + +object StatementCache { + data class StatementKey( + val sql: String, + val dbHashcode: Int, + ) + + val cachedStatements = LruCache(10) + + fun get( + sql: String, + db: SQLiteDatabase, + ): SQLiteStatement { + val key = StatementKey(sql, db.hashCode()) + val cached = cachedStatements.get(key) + return if (cached != null) { + cached.clearBindings() + cached + } else { + val stat = db.compileStatement(sql) + cachedStatements.put(key, stat) + stat + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt new file mode 100644 index 0000000000..c2cd3a6e61 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt @@ -0,0 +1,84 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite.sql + +sealed class Condition { + data class Equals( + val column: String, + val value: Any?, + ) : Condition() + + data class NotEquals( + val column: String, + val value: Any?, + ) : Condition() + + data class GreaterThan( + val column: String, + val value: Any, + ) : Condition() + + data class GreaterThanOrEquals( + val column: String, + val value: Any, + ) : Condition() + + data class LessThan( + val column: String, + val value: Any, + ) : Condition() + + data class LessThanOrEquals( + val column: String, + val value: Any, + ) : Condition() + + data class Like( + val column: String, + val value: String, + ) : Condition() + + data class Match( + val table: String, + val value: String, + ) : Condition() + + data class IsNull( + val column: String, + ) : Condition() + + data class IsNotNull( + val column: String, + ) : Condition() + + data class In( + val column: String, + val values: List, + ) : Condition() + + data class And( + val conditions: List, + ) : Condition() + + data class Or( + val conditions: List, + ) : Condition() +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt new file mode 100644 index 0000000000..7e898c2445 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt @@ -0,0 +1,106 @@ +/** + * 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.quartz.nip01Core.store.sqlite.sql + +class SqlSelectionBuilder( + private val condition: Condition, +) { + private val selectionArgs = mutableListOf() + + fun build(): WhereClause { + selectionArgs.clear() // Clear previous args for a fresh build + val conditions = buildCondition(condition) + return WhereClause(conditions, selectionArgs) + } + + /** + * Recursively builds the SQL string for a given condition. + * @param cond The [Condition] to build the SQL string for. + * @return The SQL string representation of the condition. + */ + private fun buildCondition(cond: Condition): String = + when (cond) { + is Condition.Equals -> { + selectionArgs.add(cond.value.toString()) + "${cond.column} = ?" + } + is Condition.NotEquals -> { + selectionArgs.add(cond.value.toString()) + "${cond.column} != ?" + } + is Condition.GreaterThan -> { + selectionArgs.add(cond.value.toString()) + "${cond.column} > ?" + } + is Condition.GreaterThanOrEquals -> { + selectionArgs.add(cond.value.toString()) + "${cond.column} >= ?" + } + is Condition.LessThan -> { + selectionArgs.add(cond.value.toString()) + "${cond.column} < ?" + } + is Condition.LessThanOrEquals -> { + selectionArgs.add(cond.value.toString()) + "${cond.column} <= ?" + } + is Condition.Like -> { + selectionArgs.add(cond.value) + "${cond.column} LIKE ?" + } + is Condition.Match -> { + selectionArgs.add(cond.value) + "${cond.table} MATCH ?" + } + is Condition.IsNull -> { + "${cond.column} IS NULL" + } + is Condition.IsNotNull -> { + "${cond.column} IS NOT NULL" + } + is Condition.In -> { + if (cond.values.isEmpty()) { + // Handle empty IN clause gracefully, perhaps by making it always false + // or throwing an error, depending on desired behavior. + // For now, let's make it an always false condition to avoid SQL errors. + "1 = 0" // Always false + } else { + val placeholders = cond.values.joinToString(", ") { "?" } + cond.values.forEach { selectionArgs.add(it.toString()) } + "${cond.column} IN ($placeholders)" + } + } + is Condition.And -> { + if (cond.conditions.isEmpty()) { + "1 = 1" // Always true for an empty AND + } else { + cond.conditions.joinToString(" AND ") { "(${buildCondition(it)})" } + } + } + is Condition.Or -> { + if (cond.conditions.isEmpty()) { + "1 = 0" // Always false for an empty OR + } else { + cond.conditions.joinToString(" OR ") { "(${buildCondition(it)})" } + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt new file mode 100644 index 0000000000..817ab0ea23 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt @@ -0,0 +1,120 @@ +/** + * 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.quartz.nip01Core.store.sqlite.sql + +class WhereClauseBuilder { + private val conditions = mutableListOf() + + fun equals( + column: String, + value: Any?, + ) = apply { conditions.add(Condition.Equals(column, value)) } + + fun notEquals( + column: String, + value: Any?, + ) = apply { conditions.add(Condition.NotEquals(column, value)) } + + fun greaterThan( + column: String, + value: Any, + ) = apply { conditions.add(Condition.GreaterThan(column, value)) } + + fun greaterThanOrEquals( + column: String, + value: Any, + ) = apply { conditions.add(Condition.GreaterThanOrEquals(column, value)) } + + fun lessThan( + column: String, + value: Any, + ) = apply { conditions.add(Condition.LessThan(column, value)) } + + fun lessThanOrEquals( + column: String, + value: Any, + ) = apply { conditions.add(Condition.LessThanOrEquals(column, value)) } + + fun like( + column: String, + pattern: String, + ) = apply { conditions.add(Condition.Like(column, pattern)) } + + fun match( + table: String, + pattern: String, + ) = apply { conditions.add(Condition.Match(table, pattern)) } + + fun isNull(column: String) = apply { conditions.add(Condition.IsNull(column)) } + + fun isNotNull(column: String) = apply { conditions.add(Condition.IsNotNull(column)) } + + fun isIn( + column: String, + values: List, + ) = apply { conditions.add(Condition.In(column, values)) } + + fun equalsOrIn( + column: String, + values: List, + ) = apply { + if (values.size == 1) { + equals(column, values.first()) + } else { + isIn(column, values) + } + } + + fun and(block: WhereClauseBuilder.() -> Unit) = + apply { + val builder = WhereClauseBuilder().apply(block) + val builtCondition = builder.build() + if (builtCondition != null) { + conditions.add(builtCondition) + } + } + + fun or(block: WhereClauseBuilder.() -> Unit) = + apply { + val builder = WhereClauseBuilder().apply(block) + val builtCondition = builder.build() + if (builtCondition != null) { + conditions.add(builtCondition) + } + } + + fun build(): Condition? = + when (conditions.size) { + 0 -> null + 1 -> conditions.first() + else -> Condition.And(conditions.toList()) + } +} + +fun where(block: WhereClauseBuilder.() -> Unit): WhereClause { + val condition = WhereClauseBuilder().apply(block).build() ?: Condition.And(emptyList()) + return SqlSelectionBuilder(condition).build() +} + +class WhereClause( + val conditions: String, + val args: List, +) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/ATag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/ATag.kt index 07ba6ac599..74a0ac49bc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/ATag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/ATag.kt @@ -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,33 +24,32 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.ensure import com.vitorpamplona.quartz.utils.pointerSizeInBytes -import com.vitorpamplona.quartz.utils.removeTrailingNullsAndEmptyOthers @Immutable data class ATag( val kind: Int, - val pubKeyHex: String, - val dTag: String, - val relay: String? = null, + val pubKeyHex: HexKey, + val dTag: String = "", + val relay: NormalizedRelayUrl? = null, ) { - constructor(address: Address, relayHint: String? = null) : this(address.kind, address.pubKeyHex, address.dTag, relayHint) + constructor(address: Address, relayHint: NormalizedRelayUrl? = null) : this(address.kind, address.pubKeyHex, address.dTag, relayHint) fun countMemory(): Long = 5 * pointerSizeInBytes + // 7 fields, 4 bytes each reference (32bit) 8L + // kind pubKeyHex.bytesUsedInMemory() + dTag.bytesUsedInMemory() + - (relay?.bytesUsedInMemory() ?: 0) + (relay?.url?.bytesUsedInMemory() ?: 0) fun toTag() = Address.assemble(kind, pubKeyHex, dTag) - fun toATagArray() = removeTrailingNullsAndEmptyOthers(TAG_NAME, toTag(), relay) - - fun toQTagArray() = removeTrailingNullsAndEmptyOthers("q", toTag(), relay) + fun toATagArray() = assemble(toTag(), relay) companion object { const val TAG_NAME = "a" @@ -109,7 +108,14 @@ data class ATag( fun parse( aTagId: String, relay: String?, - ) = Address.parse(aTagId)?.let { ATag(it.kind, it.pubKeyHex, it.dTag, relay) } + ) = Address.parse(aTagId)?.let { + ATag( + it.kind, + it.pubKeyHex, + it.dTag, + relay?.let { RelayUrlNormalizer.normalizeOrNull(it) }, + ) + } @JvmStatic fun parse(tag: Array): ATag? { @@ -150,21 +156,31 @@ data class ATag( ensure(tag[1].isNotEmpty()) { return null } ensure(tag[1].contains(':')) { return null } ensure(tag[2].isNotEmpty()) { return null } - return AddressHint(tag[1], tag[2]) + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(relayHint != null) { return null } + + return AddressHint(tag[1], relayHint) } @JvmStatic fun assemble( aTagId: HexKey, - relay: String?, - ) = arrayOfNotNull(TAG_NAME, aTagId, relay) + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, aTagId, relay?.url) + + @JvmStatic + fun assemble( + address: Address, + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, address.toValue(), relay?.url) @JvmStatic fun assemble( kind: Int, pubKey: String, dTag: String, - relay: String?, + relay: NormalizedRelayUrl?, ) = assemble(Address.assemble(kind, pubKey, dTag), relay) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/Address.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/Address.kt index d09eae45b0..c2ad939015 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/Address.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/Address.kt @@ -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,15 +22,18 @@ package com.vitorpamplona.quartz.nip01Core.tags.addressables import android.util.Log import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.pointerSizeInBytes data class Address( - val kind: Int, + val kind: Kind, val pubKeyHex: HexKey, - val dTag: String, -) { + val dTag: String = "", +) : Comparable
{ fun toValue() = assemble(kind, pubKeyHex, dTag) fun countMemory(): Long = @@ -39,27 +42,53 @@ data class Address( pubKeyHex.bytesUsedInMemory() + dTag.bytesUsedInMemory() + override fun compareTo(other: Address): Int { + val result = kind.compareTo(other.kind) + if (result == 0) { + val result2 = pubKeyHex.compareTo(other.pubKeyHex) + if (result2 == 0) { + return dTag.compareTo(other.dTag) + } else { + return result2 + } + } else { + return result + } + } + companion object { fun assemble( kind: Int, pubKeyHex: HexKey, - dTag: String, + dTag: String = "", ) = "$kind:$pubKeyHex:$dTag" @JvmStatic - fun parse(addressId: String): Address? = - try { + fun parse(addressId: String): Address? { + if (addressId.isBlank()) return null + return try { val parts = addressId.split(":", limit = 3) - if (parts.size > 1 && parts[1].length == 64 && Hex.isHex(parts[1])) { - Address(parts[0].toInt(), parts[1], parts[2]) + if (parts.size > 2 && parts[1].length == 64 && Hex.isHex(parts[1])) { + Address(parts[0].toInt(), parts[1], parts.getOrNull(2) ?: "") } else { - Log.w("AddressableId", "Error parsing. Pubkey is not hex: $addressId") - null + if (addressId.startsWith("naddr1")) { + val addr = Nip19Parser.uriToRoute(addressId)?.entity + if (addr is NAddress) { + addr.address() + } else { + Log.w("AddressableId", "Error parsing. naddr1 seems invalid: $addressId") + null + } + } else { + Log.w("AddressableId", "Error parsing. Not a valid address: $addressId") + null + } } } catch (t: Throwable) { Log.e("AddressableId", "Error parsing: $addressId: ${t.message}", t) null } + } fun isOfKind( addressId: String, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/EventExt.kt index 1759536487..ef28d4089b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/EventExt.kt @@ -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,9 +26,9 @@ fun Event.mapTaggedAddress(map: (address: String) -> R) = tags.mapTaggedAddr fun Event.firstIsTaggedAddressableNote(addressableNotes: Set) = tags.firstIsTaggedAddressableNote(addressableNotes) -fun Event.isTaggedAddressableNote(idHex: String) = tags.isTaggedAddressableNote(idHex) +fun Event.isTaggedAddressableNote(addressId: String) = tags.isTaggedAddressableNote(addressId) -fun Event.isTaggedAddressableNotes(idHexes: Set) = tags.isTaggedAddressableNotes(idHexes) +fun Event.isTaggedAddressableNotes(addressIds: Set) = tags.isTaggedAddressableNotes(addressIds) fun Event.isTaggedAddressableKind(kind: Int) = tags.isTaggedAddressableKind(kind) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/TagArrayBuilderExt.kt index d59535ad3a..3b494e0e93 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/TagArrayBuilderExt.kt @@ -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,6 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.tags.addressables import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip18Reposts.quotes.toQTagArray fun TagArrayBuilder.aTag(tag: ATag) = add(tag.toATagArray()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/TagArrayExt.kt index d7e8deb0e8..11f6e514d4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/TagArrayExt.kt @@ -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,12 +22,12 @@ package com.vitorpamplona.quartz.nip01Core.tags.addressables import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.any -import com.vitorpamplona.quartz.nip01Core.core.firstNotNullOfOrNull +import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull import com.vitorpamplona.quartz.nip01Core.core.mapValueTagged fun TagArray.mapTaggedAddress(map: (address: String) -> R) = this.mapValueTagged(ATag.TAG_NAME, map) -fun TagArray.firstIsTaggedAddressableNote(addressableNotes: Set) = this.firstNotNullOfOrNull(ATag::parseIfIsIn, addressableNotes) +fun TagArray.firstIsTaggedAddressableNote(addressableNotes: Set) = this.fastFirstNotNullOfOrNull(ATag::parseIfIsIn, addressableNotes) fun TagArray.isTaggedAddressableNote(addressId: String) = this.any(ATag::isTagged, addressId) @@ -35,12 +35,12 @@ fun TagArray.isTaggedAddressableNotes(addressIds: Set) = this.any(ATag:: fun TagArray.isTaggedAddressableKind(kind: Int) = this.any(ATag::isTaggedWithKind, kind.toString()) -fun TagArray.getTagOfAddressableKind(kind: Int) = this.firstNotNullOfOrNull(ATag::parseIfOfKind, kind.toString()) +fun TagArray.getTagOfAddressableKind(kind: Int) = this.fastFirstNotNullOfOrNull(ATag::parseIfOfKind, kind.toString()) fun TagArray.taggedATags() = this.mapNotNull(ATag::parse) -fun TagArray.firstTaggedATag() = this.firstNotNullOfOrNull(ATag::parse) +fun TagArray.firstTaggedATag() = this.fastFirstNotNullOfOrNull(ATag::parse) fun TagArray.taggedAddresses() = this.mapNotNull(ATag::parseAddress) -fun TagArray.firstTaggedAddress() = this.firstNotNullOfOrNull(ATag::parseAddress) +fun TagArray.firstTaggedAddress() = this.fastFirstNotNullOfOrNull(ATag::parseAddress) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/dTags/DTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/dTags/DTag.kt index b5d6a3a6a4..e439d00d7a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/dTags/DTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/dTags/DTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/dTags/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/dTags/EventExt.kt index 4c5035d497..a4a0b32747 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/dTags/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/dTags/EventExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/dTags/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/dTags/TagArrayBuilderExt.kt index a85f6be7bc..1cd4cb49f9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/dTags/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/dTags/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/dTags/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/dTags/TagArrayExt.kt index efc7969d00..8e8bb9781d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/dTags/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/dTags/TagArrayExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/ETag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/ETag.kt index de79427091..1a793e857e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/ETag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/ETag.kt @@ -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,6 +24,8 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.bytesUsedInMemory @@ -34,10 +36,10 @@ import com.vitorpamplona.quartz.utils.pointerSizeInBytes data class ETag( override val eventId: HexKey, ) : GenericETag { - override var relay: String? = null + override var relay: NormalizedRelayUrl? = null override var author: HexKey? = null - constructor(eventId: HexKey, relayHint: String? = null, authorPubKeyHex: HexKey? = null) : this(eventId) { + constructor(eventId: HexKey, relayHint: NormalizedRelayUrl? = null, authorPubKeyHex: HexKey? = null) : this(eventId) { this.relay = relayHint this.author = authorPubKeyHex } @@ -45,16 +47,12 @@ data class ETag( fun countMemory(): Long = 3 * pointerSizeInBytes + // 3 fields, 4 bytes each reference (32bit) eventId.bytesUsedInMemory() + - (relay?.bytesUsedInMemory() ?: 0) + + (relay?.url?.bytesUsedInMemory() ?: 0) + (author?.bytesUsedInMemory() ?: 0) fun toNEvent(): String = NEvent.create(eventId, author, null, relay) - override fun toTagArray() = toNamedTagArray(TAG_NAME) - - fun toQTagArray() = toNamedTagArray("q") - - fun toNamedTagArray(key: String) = arrayOfNotNull(key, eventId, relay, author) + override fun toTagArray() = assemble(eventId, relay, author) companion object { const val TAG_NAME = "e" @@ -73,7 +71,8 @@ data class ETag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return ETag(tag[1], tag.getOrNull(2), tag.getOrNull(3)) + + return ETag(tag[1], pickRelayHint(tag), pickAuthor(tag)) } @JvmStatic @@ -84,20 +83,51 @@ data class ETag( return tag[1] } + // simple case ["e", "id", "relay"] + // empty tags ["e", "id", "relay", ""] + // current root ["e", "id", "relay", "marker"] + // current root ["e", "id", "relay", "marker", "pubkey"] + // empty tags ["e", "id", "relay", "", "pubkey"] + // pubkey marker ["e", "id", "relay", "pubkey"] + // pubkey marker ["e", "id", "relay", "pubkey", "marker"] + // pubkey marker ["e", "id", "pubkey"] // incorrect + // current root ["e", "id", "marker"] // incorrect + + @JvmStatic + private fun pickRelayHint(tag: Array): NormalizedRelayUrl? { + if (tag.has(2) && tag[2].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[2])) return RelayUrlNormalizer.normalizeOrNull(tag[2]) + if (tag.has(3) && tag[3].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[3])) return RelayUrlNormalizer.normalizeOrNull(tag[3]) + if (tag.has(4) && tag[4].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[4])) return RelayUrlNormalizer.normalizeOrNull(tag[4]) + return null + } + + @JvmStatic + private fun pickAuthor(tag: Array): HexKey? { + if (tag.has(2) && tag[2].length == 64) return tag[2] + if (tag.has(3) && tag[3].length == 64) return tag[3] + if (tag.has(4) && tag[4].length == 64) return tag[4] + return null + } + @JvmStatic fun parseAsHint(tag: Array): EventIdHint? { ensure(tag.has(2)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } ensure(tag[2].isNotEmpty()) { return null } - return EventIdHint(tag[1], tag[2]) + + val hint = pickRelayHint(tag) + + ensure(hint != null) { return null } + + return EventIdHint(tag[1], hint) } @JvmStatic fun assemble( eventId: HexKey, - relay: String?, + relay: NormalizedRelayUrl?, author: HexKey?, - ) = arrayOfNotNull(TAG_NAME, eventId, relay, author) + ) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, author) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/EventExt.kt index 883b722d4e..c63fa2f263 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/EventExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/EventReference.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/EventReference.kt index 8728ff3b4b..6b86db0fc1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/EventReference.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/EventReference.kt @@ -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,9 +21,10 @@ package com.vitorpamplona.quartz.nip01Core.tags.events import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class EventReference( val eventId: HexKey, val author: HexKey?, - val relayHint: String?, + val relayHint: NormalizedRelayUrl?, ) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/GenericETag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/GenericETag.kt index b10a836f42..4d2fb2426e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/GenericETag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/GenericETag.kt @@ -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,10 +21,11 @@ package com.vitorpamplona.quartz.nip01Core.tags.events import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl interface GenericETag { val eventId: HexKey - val relay: String? + val relay: NormalizedRelayUrl? val author: HexKey? fun toTagArray(): Array diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/TagArrayBuilderExt.kt index 260d464e15..d45141c0e8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/TagArrayBuilderExt.kt @@ -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,6 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.tags.events import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip18Reposts.quotes.toQTagArray fun TagArrayBuilder.eTag(tag: ETag) = add(tag.toTagArray()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/TagArrayExt.kt index 88c11e6fad..a4be2a93e9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/TagArrayExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/EventExt.kt index 37b17d87cb..060dea65d1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/EventExt.kt @@ -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,38 +22,21 @@ package com.vitorpamplona.quartz.nip01Core.tags.geohash import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip73ExternalIds.location.geohashedScope -fun Event.hasGeohashes() = - if (this is CommentEvent) { - this.hasGeohashes() - } else { - tags.hasGeohashes() - } +fun Event.hasGeohashes() = tags.hasGeohashes() -fun Event.isTaggedGeoHashes(hashtags: Set) = - if (this is CommentEvent) { - this.isTaggedGeoHashes(hashtags) - } else { - tags.isTaggedGeoHashes(hashtags) - } +fun Event.isTaggedGeoHashes(hashtags: Set) = tags.isTaggedGeoHashes(hashtags) -fun Event.isTaggedGeoHash(hashtag: String) = - if (this is CommentEvent) { - this.isTaggedGeoHash(hashtag) - } else { - tags.isTaggedGeoHash(hashtag) - } +fun Event.isTaggedGeoHash(hashtag: String) = tags.isTaggedGeoHash(hashtag) -fun Event.geohashes() = - if (this is CommentEvent) { - geohashes() - } else { - tags.geohashes() - } +fun Event.geohashes() = tags.geohashes() -fun Event.getGeoHash(): String? = +fun Event.getGeoHash(): String? = tags.getGeoHash() + +fun Event.geoHashOrScope() = if (this is CommentEvent) { - getGeoHash() + geohashedScope() } else { tags.getGeoHash() } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/GeoHash.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/GeoHashTag.kt similarity index 70% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/GeoHash.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/GeoHashTag.kt index 650351ccd7..5518184dab 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/GeoHash.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/GeoHashTag.kt @@ -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,15 +21,30 @@ package com.vitorpamplona.quartz.nip01Core.tags.geohash import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure -class GeoHash { +class GeoHashTag { companion object { const val TAG_NAME = "g" + @JvmStatic + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun parse(tags: Array): String? { + ensure(tags.has(1)) { return null } + ensure(tags[0] == TAG_NAME) { return null } + return tags[1] + } + + @JvmStatic + fun assembleSingle(geohash: String) = arrayOf(TAG_NAME, geohash) + @JvmStatic fun geoMipMap(geohash: String): List = geohash.indices.map { geohash.substring(0, it + 1) }.reversed() - fun geohashMipMap(geohash: String): TagArray = geoMipMap(geohash).map { arrayOf(TAG_NAME, it) }.toTypedArray() + fun geohashMipMap(geohash: String): TagArray = geoMipMap(geohash).map { assembleSingle(it) }.toTypedArray() fun assemble(geohash: String) = geohashMipMap(geohash) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/GeohashPrecision.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/GeohashPrecision.kt index 98109f3749..a5abeb105e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/GeohashPrecision.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/GeohashPrecision.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/TagArrayBuilderExt.kt index 660315e321..d01517e53c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/TagArrayBuilderExt.kt @@ -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,4 +23,4 @@ package com.vitorpamplona.quartz.nip01Core.tags.geohash import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder -fun TagArrayBuilder.geohash(tag: String) = addAll(GeoHash.assemble(tag)) +fun TagArrayBuilder.geohash(tag: String) = addAll(GeoHashTag.assemble(tag)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/TagArrayExt.kt index f4be576597..1aa5fb6b1b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/TagArrayExt.kt @@ -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,14 +24,13 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.anyTagWithValueStartingWith import com.vitorpamplona.quartz.nip01Core.core.hasTagWithContent import com.vitorpamplona.quartz.nip01Core.core.isAnyTagged -import com.vitorpamplona.quartz.nip01Core.core.mapValues -fun TagArray.hasGeohashes() = this.hasTagWithContent(GeoHash.TAG_NAME) +fun TagArray.hasGeohashes() = this.hasTagWithContent(GeoHashTag.TAG_NAME) -fun TagArray.isTaggedGeoHashes(hashtags: Set) = this.isAnyTagged(GeoHash.TAG_NAME, hashtags) +fun TagArray.isTaggedGeoHashes(hashtags: Set) = this.isAnyTagged(GeoHashTag.TAG_NAME, hashtags) -fun TagArray.isTaggedGeoHash(hashtag: String) = this.anyTagWithValueStartingWith(GeoHash.TAG_NAME, hashtag) +fun TagArray.isTaggedGeoHash(hashtag: String) = this.anyTagWithValueStartingWith(GeoHashTag.TAG_NAME, hashtag) -fun TagArray.geohashes() = this.mapValues(GeoHash.TAG_NAME) +fun TagArray.geohashes() = this.mapNotNull(GeoHashTag::parse) fun TagArray.getGeoHash(): String? = geohashes().maxByOrNull { it.length }?.ifBlank { null } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/EventExt.kt index 81da5a7d02..b490c16fb2 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/EventExt.kt @@ -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,9 +21,6 @@ package com.vitorpamplona.quartz.nip01Core.tags.hashtags import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey - -fun Event.forEachHashTag(onEach: (eventId: HexKey) -> Unit) = tags.forEachHashTag(onEach) fun Event.anyHashTag(onEach: (str: String) -> Boolean) = tags.anyHashTag(onEach) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/HashtagTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/HashtagTag.kt index ac11b4d46b..581e65a05c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/HashtagTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/HashtagTag.kt @@ -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 @@ -30,6 +30,16 @@ class HashtagTag { @JvmStatic fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + fun isTagged( + tag: Array, + hashtag: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == hashtag + + fun isAnyTagged( + tag: Array, + hashtags: Set, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in hashtags + @JvmStatic fun parse(tag: Array): String? { ensure(tag.has(1)) { return null } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/MipMapHashTags.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/MipMapHashTags.kt new file mode 100644 index 0000000000..c11dac244e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/MipMapHashTags.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.tags.hashtags + +import java.util.Locale + +fun hashtagAlts(tag: String): Set = + setOf( + tag, + tag.lowercase(), + tag.uppercase(), + tag.replaceFirstChar { + if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString() + }, + ) + +fun hashtagAlts(tags: List): Set = tags.map { hashtagAlts(it) }.flatten().toSet() + +fun hashtagAlts(tags: Set): Set = tags.map { hashtagAlts(it) }.flatten().toSet() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/TagArrayBuilderExt.kt index a6f7cee6de..6c017769b4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/TagArrayExt.kt index 849e381672..9b985b7837 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/TagArrayExt.kt @@ -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,18 +25,16 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.anyTagged import com.vitorpamplona.quartz.nip01Core.core.firstAnyLowercaseTaggedValue import com.vitorpamplona.quartz.nip01Core.core.forEachTagged -import com.vitorpamplona.quartz.nip01Core.core.hasTagWithContent import com.vitorpamplona.quartz.nip01Core.core.isAnyLowercaseTagged import com.vitorpamplona.quartz.nip01Core.core.isTagged -import com.vitorpamplona.quartz.nip01Core.core.mapValues fun TagArray.forEachHashTag(onEach: (eventId: HexKey) -> Unit) = this.forEachTagged(HashtagTag.TAG_NAME, onEach) fun TagArray.anyHashTag(onEach: (str: String) -> Boolean) = this.anyTagged(HashtagTag.TAG_NAME, onEach) -fun TagArray.hasHashtags() = this.hasTagWithContent(HashtagTag.TAG_NAME) +fun TagArray.hasHashtags() = this.any(HashtagTag::isTagged) -fun TagArray.hashtags() = this.mapValues(HashtagTag.TAG_NAME) +fun TagArray.hashtags() = this.mapNotNull(HashtagTag::parse) fun TagArray.countHashtags() = this.count(HashtagTag::isTagged) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/kinds/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/kinds/EventExt.kt index 14fa8e1eb9..cddb424b13 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/kinds/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/kinds/EventExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/kinds/KindTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/kinds/KindTag.kt index c076c4dee7..a3b9ad43fc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/kinds/KindTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/kinds/KindTag.kt @@ -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,6 +20,7 @@ */ package com.vitorpamplona.quartz.nip01Core.tags.kinds +import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.utils.ensure @@ -27,7 +28,7 @@ class KindTag { companion object { const val TAG_NAME = "k" - fun match(tag: Array) = tag.has(1) && tag[0] == TAG_NAME + fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME fun isTagged( tag: Array, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/kinds/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/kinds/TagArrayBuilderExt.kt index 913f096f5c..3133a4540a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/kinds/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/kinds/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/kinds/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/kinds/TagArrayExt.kt index c3238ee5de..2a27baa1a7 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/kinds/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/kinds/TagArrayExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/EntityExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/EntityExt.kt index d44a1e696a..c9b7b4fd0b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/EntityExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/EntityExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/EventExt.kt index 6248f93539..fbed3e5531 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/EventExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/PTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/PTag.kt index 53e941df9d..1b712af7ab 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/PTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/PTag.kt @@ -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,13 +20,14 @@ */ package com.vitorpamplona.quartz.nip01Core.tags.people -import android.util.Log import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.utils.arrayOfNotNull @@ -37,12 +38,12 @@ import com.vitorpamplona.quartz.utils.pointerSizeInBytes @Immutable data class PTag( override val pubKey: HexKey, - override val relayHint: String? = null, + override val relayHint: NormalizedRelayUrl? = null, ) : PubKeyReferenceTag { fun countMemory(): Long = 2 * pointerSizeInBytes + // 2 fields, 4 bytes each reference (32bit) pubKey.bytesUsedInMemory() + - (relayHint?.bytesUsedInMemory() ?: 0) + (relayHint?.url?.bytesUsedInMemory() ?: 0) fun toNProfile(): String = NProfile.create(pubKey, relayHint?.let { listOf(it) } ?: emptyList()) @@ -63,17 +64,22 @@ data class PTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return PTag(tag[1], tag.getOrNull(2)) + + val hint = pickRelayHint(tag) + + return PTag(tag[1], hint) + } + + private fun pickRelayHint(tag: Array): NormalizedRelayUrl? { + if (tag.has(2) && tag[2].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[2])) return RelayUrlNormalizer.normalizeOrNull(tag[2]) + return null } @JvmStatic fun parseKey(tag: Array): HexKey? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(tag[1].length == 64) { - Log.w("PTag", "Invalid `$TAG_NAME` value ${tag.joinToString(", ")}") - return null - } + ensure(tag[1].length == 64) { return null } return tag[1] } @@ -83,13 +89,18 @@ data class PTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } ensure(tag[2].isNotEmpty()) { return null } - return PubKeyHint(tag[1], tag[2]) + + val hint = pickRelayHint(tag) + + ensure(hint != null) { return null } + + return PubKeyHint(tag[1], hint) } @JvmStatic fun assemble( pubkey: HexKey, - relayHint: String?, - ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint) + relayHint: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint?.url) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/PubKeyReferenceTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/PubKeyReferenceTag.kt index 7e36107d4a..27750180bf 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/PubKeyReferenceTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/PubKeyReferenceTag.kt @@ -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,9 @@ package com.vitorpamplona.quartz.nip01Core.tags.people import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl interface PubKeyReferenceTag { val pubKey: HexKey - val relayHint: String? + val relayHint: NormalizedRelayUrl? } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/TagArrayBuilderExt.kt index f3728d5da5..d622c41b2b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/TagArrayBuilderExt.kt @@ -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,10 +23,11 @@ package com.vitorpamplona.quartz.nip01Core.tags.people import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl fun TagArrayBuilder.pTag( pubkey: HexKey, - relayHint: String? = null, + relayHint: NormalizedRelayUrl? = null, ) = add(PTag.assemble(pubkey, relayHint)) fun TagArrayBuilder.pTagIds(tag: Set) = addAll(tag.map { PTag.assemble(it, null) }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/TagArrayExt.kt index e8d099ba9f..c40f9c020f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/TagArrayExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/references/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/references/EventExt.kt index 679879c915..5b007b9794 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/references/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/references/EventExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/references/ReferenceTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/references/ReferenceTag.kt index 1ad8b8f29d..aeba87840b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/references/ReferenceTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/references/ReferenceTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/references/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/references/TagArrayBuilderExt.kt index ae58cf4cac..bbc611020a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/references/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/references/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/references/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/references/TagArrayExt.kt index 4a3fb5427f..9d6bc0990a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/references/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/references/TagArrayExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt index a5d995afbc..edf55518c8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt @@ -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,21 +21,17 @@ package com.vitorpamplona.quartz.nip02FollowList import androidx.compose.runtime.Stable -import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNote -import com.vitorpamplona.quartz.nip01Core.tags.events.isTaggedEvent -import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHash -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.countHashtags import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser -import com.vitorpamplona.quartz.nip02FollowList.tags.AddressFollowTag import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils @@ -48,13 +44,17 @@ class ContactListEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig), +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), AddressHintProvider, PubKeyHintProvider { override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + override fun pubKeyHints() = tags.mapNotNull(ContactTag::parseAsHint) + override fun linkedPubKeys() = tags.mapNotNull(ContactTag::parseKey) + /** * Returns a list of p-tags that are verified as hex keys. */ @@ -63,19 +63,30 @@ class ContactListEvent( /** * Returns a list of a-tags that are verified as correct. */ - fun verifiedFollowAddressSet(): Set = tags.mapNotNullTo(HashSet(), AddressFollowTag::parseValidAddress) + @Deprecated("Use CommunityListEvent instead.") + fun verifiedFollowAddressSet(): Set = tags.mapNotNullTo(HashSet(), ATag::parseValidAddress) fun unverifiedFollowKeySet() = tags.mapNotNull(ContactTag::parseKey) + @Deprecated("Use HashtagListEvent instead.") fun unverifiedFollowTagSet() = tags.hashtags() - fun countFollowTags() = tags.countHashtags() - fun follows() = tags.mapNotNull(ContactTag::parseValid) - fun followsTags() = hashtags() + fun relays(): Map? { + val regular = RelaySet.parse(content) - fun relays(): Map? = RelaySet.parse(content) + val normalized = mutableMapOf() + + regular?.forEach { + val key = RelayUrlNormalizer.normalizeOrNull(it.key) + if (key != null) { + normalized.put(key, it.value) + } + } + + return normalized + } companion object { const val KIND = 3 @@ -83,249 +94,61 @@ class ContactListEvent( fun blockListFor(pubKeyHex: HexKey): String = "3:$pubKeyHex:" - fun createFromScratch( - followUsers: List = emptyList(), - followTags: List = emptyList(), - followGeohashes: List = emptyList(), - followCommunities: List = emptyList(), - followEvents: List = emptyList(), - relayUse: Map? = emptyMap(), - signer: NostrSignerSync, - createdAt: Long = TimeUtils.now(), - ): ContactListEvent? { - val content = relayUse?.let { RelaySet.assemble(it) } ?: "" - - val tags = - listOf(AltTag.assemble(ALT)) + - followUsers.map { it.toTagArray() } + - followTags.map { arrayOf("t", it) } + - followEvents.map { arrayOf("e", it) } + - followCommunities.map { it.toATagArray() } + - followGeohashes.map { arrayOf("g", it) } - - return signer.sign(createdAt, KIND, tags.toTypedArray(), content) - } - - fun createFromScratch( + suspend fun createFromScratch( followUsers: List, - followTags: List, - followGeohashes: List, - followCommunities: List, - followEvents: List, relayUse: Map?, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { + ): ContactListEvent { val content = relayUse?.let { RelaySet.assemble(it) } ?: "" - val tags = - followUsers.map { it.toTagArray() } + - followTags.map { arrayOf("t", it) } + - followEvents.map { arrayOf("e", it) } + - followCommunities.map { it.toATagArray() } + - followGeohashes.map { arrayOf("g", it) } - + val tags = followUsers.map { it.toTagArray() } return create( content = content, tags = tags.toTypedArray(), signer = signer, createdAt = createdAt, - onReady = onReady, ) } - fun followUser( + suspend fun followUser( earlierVersion: ContactListEvent, pubKeyHex: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (earlierVersion.isTaggedUser(pubKeyHex)) return + ): ContactListEvent { + if (earlierVersion.isTaggedUser(pubKeyHex)) return earlierVersion return create( content = earlierVersion.content, tags = earlierVersion.tags.plus(element = arrayOf("p", pubKeyHex)), signer = signer, createdAt = createdAt, - onReady = onReady, ) } - fun unfollowUser( + suspend fun unfollowUser( earlierVersion: ContactListEvent, pubKeyHex: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (!earlierVersion.isTaggedUser(pubKeyHex)) return + ): ContactListEvent { + if (!earlierVersion.isTaggedUser(pubKeyHex)) return earlierVersion return create( content = earlierVersion.content, tags = earlierVersion.tags.filter { it.size > 1 && it[1] != pubKeyHex }.toTypedArray(), signer = signer, createdAt = createdAt, - onReady = onReady, ) } - fun followHashtag( - earlierVersion: ContactListEvent, - hashtag: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (earlierVersion.isTaggedHash(hashtag)) return - - return create( - content = earlierVersion.content, - tags = earlierVersion.tags.plus(element = arrayOf("t", hashtag)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - - fun unfollowHashtag( - earlierVersion: ContactListEvent, - hashtag: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (!earlierVersion.isTaggedHash(hashtag)) return - - return create( - content = earlierVersion.content, - tags = - earlierVersion.tags.filter { it.size > 1 && !it[1].equals(hashtag, true) }.toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - - fun followGeohash( - earlierVersion: ContactListEvent, - hashtag: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (earlierVersion.isTaggedGeoHash(hashtag)) return - - return create( - content = earlierVersion.content, - tags = earlierVersion.tags.plus(element = arrayOf("g", hashtag)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - - fun unfollowGeohash( - earlierVersion: ContactListEvent, - hashtag: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (!earlierVersion.isTaggedGeoHash(hashtag)) return - - return create( - content = earlierVersion.content, - tags = earlierVersion.tags.filter { it.size > 1 && it[1] != hashtag }.toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - - fun followEvent( - earlierVersion: ContactListEvent, - idHex: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (earlierVersion.isTaggedEvent(idHex)) return - - return create( - content = earlierVersion.content, - tags = earlierVersion.tags.plus(element = arrayOf("e", idHex)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - - fun unfollowEvent( - earlierVersion: ContactListEvent, - idHex: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (!earlierVersion.isTaggedEvent(idHex)) return - - return create( - content = earlierVersion.content, - tags = earlierVersion.tags.filter { it.size > 1 && it[1] != idHex }.toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - - fun followAddressableEvent( - earlierVersion: ContactListEvent, - aTag: ATag, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (earlierVersion.isTaggedAddressableNote(aTag.toTag())) return - - return create( - content = earlierVersion.content, - tags = - earlierVersion.tags.plus( - element = listOfNotNull("a", aTag.toTag(), aTag.relay).toTypedArray(), - ), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - - fun unfollowAddressableEvent( - earlierVersion: ContactListEvent, - aTag: ATag, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (!earlierVersion.isTaggedAddressableNote(aTag.toTag())) return - - return create( - content = earlierVersion.content, - tags = earlierVersion.tags.filter { it.size > 1 && it[1] != aTag.toTag() }.toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - - fun updateRelayList( + suspend fun updateRelayList( earlierVersion: ContactListEvent, relayUse: Map?, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { + ): ContactListEvent { val content = relayUse?.let { RelaySet.assemble(it) } ?: "" return create( @@ -333,17 +156,15 @@ class ContactListEvent( tags = earlierVersion.tags, signer = signer, createdAt = createdAt, - onReady = onReady, ) } - fun create( + suspend fun create( content: String, tags: Array>, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { + ): ContactListEvent { val newTags = if (tags.any { it.size > 1 && it[0] == "alt" }) { tags @@ -351,7 +172,22 @@ class ContactListEvent( tags + AltTag.assemble(ALT) } - signer.sign(createdAt, KIND, newTags, content, onReady) + return signer.sign(createdAt, KIND, newTags, content) + } + + fun createFromScratch( + followUsers: List = emptyList(), + relayUse: Map? = emptyMap(), + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): ContactListEvent { + val content = relayUse?.let { RelaySet.assemble(it) } ?: "" + + val tags = + listOf(AltTag.assemble(ALT)) + + followUsers.map { it.toTagArray() } + + return signer.sign(createdAt, KIND, tags.toTypedArray(), content) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/ImmutableListOfLists.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/ImmutableListOfLists.kt index 513481ad0c..9e0d353f71 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/ImmutableListOfLists.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/ImmutableListOfLists.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/RelaySet.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/RelaySet.kt index f25233d448..5910c392c9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/RelaySet.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/RelaySet.kt @@ -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,7 +22,7 @@ package com.vitorpamplona.quartz.nip02FollowList import android.util.Log import com.fasterxml.jackson.module.kotlin.readValue -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper data class ReadWrite( val read: Boolean, @@ -31,12 +31,12 @@ data class ReadWrite( class RelaySet { companion object { - fun assemble(relayUse: Map): String = EventMapper.mapper.writeValueAsString(relayUse) + fun assemble(relayUse: Map): String = JsonMapper.mapper.writeValueAsString(relayUse) fun parse(content: String): Map? = try { if (content.isNotEmpty()) { - EventMapper.mapper.readValue>(content) + JsonMapper.mapper.readValue>(content) } else { null } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/tags/ContactTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/tags/ContactTag.kt index d40a858c7a..b67f628b7b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/tags/ContactTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/tags/ContactTag.kt @@ -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,6 +26,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.bytesUsedInMemory @@ -36,12 +38,12 @@ import com.vitorpamplona.quartz.utils.pointerSizeInBytes data class ContactTag( val pubKey: HexKey, ) { - var relayUri: String? = null + var relayUri: NormalizedRelayUrl? = null var petname: String? = null constructor( pubKey: HexKey, - relayHint: String?, + relayHint: NormalizedRelayUrl?, petname: String?, ) : this(pubKey) { this.relayUri = relayHint @@ -51,7 +53,7 @@ data class ContactTag( fun countMemory(): Long = 3 * pointerSizeInBytes + pubKey.bytesUsedInMemory() + - (relayUri?.bytesUsedInMemory() ?: 0) + + (relayUri?.url?.bytesUsedInMemory() ?: 0) + (petname?.bytesUsedInMemory() ?: 0) fun toTagArray() = assemble(pubKey, relayUri, petname) @@ -67,7 +69,10 @@ data class ContactTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return ContactTag(tag[1], tag.getOrNull(2), tag.getOrNull(3)) + + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return ContactTag(tag[1], hint, tag.getOrNull(3)) } @JvmStatic @@ -75,8 +80,11 @@ data class ContactTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } + + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + return try { - ContactTag(decodePublicKey(tag[1]).toHexKey(), tag.getOrNull(2), tag.getOrNull(3)) + ContactTag(decodePublicKey(tag[1]).toHexKey(), hint, tag.getOrNull(3)) } catch (e: Exception) { Log.w("ContactTag", "Can't parse contact list p-tag ${tag.joinToString(", ")}", e) null @@ -110,14 +118,18 @@ data class ContactTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } ensure(tag[2].isNotEmpty()) { return null } - return PubKeyHint(tag[1], tag[2]) + + val hint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(hint != null) { return null } + + return PubKeyHint(tag[1], hint) } @JvmStatic fun assemble( pubkey: HexKey, - relayUri: String? = null, + relayUri: NormalizedRelayUrl? = null, petname: String? = null, - ) = arrayOfNotNull(TAG_NAME, pubkey, relayUri, petname) + ) = arrayOfNotNull(TAG_NAME, pubkey, relayUri?.url, petname) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/OtsEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/OtsEvent.kt index d6f2362753..d05879a26a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/OtsEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/OtsEvent.kt @@ -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 @@ -45,6 +45,8 @@ class OtsEvent( EventHintProvider { override fun eventHints() = tags.mapNotNull(TargetEventTag::parseAsHint) + override fun linkedEventIds() = tags.mapNotNull(TargetEventTag::parseId) + override fun isContentEncoded() = true fun digestEventId() = tags.firstNotNullOfOrNull(TargetEventTag::parseId) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/OtsResolver.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/OtsResolver.kt index e7bff90eb4..4d560b1e9d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/OtsResolver.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/OtsResolver.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/OtsResolverBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/OtsResolverBuilder.kt new file mode 100644 index 0000000000..51c7f616d9 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/OtsResolverBuilder.kt @@ -0,0 +1,25 @@ +/** + * 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.quartz.nip03Timestamp + +interface OtsResolverBuilder { + fun build(): OtsResolver +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/TagArrayBuilderExt.kt index c42a2296a7..7520add600 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/VerificationState.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/VerificationState.kt index 26b5608219..7e117f96ca 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/VerificationState.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/VerificationState.kt @@ -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 @@ -44,4 +44,7 @@ sealed class VerificationState { val errorMessage: String, val time: Long = TimeUtils.now(), ) : VerificationState() + + @Immutable + object Verifying : VerificationState() } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/VerificationStateCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/VerificationStateCache.kt index 9c31d011f1..3e59d83bdb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/VerificationStateCache.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/VerificationStateCache.kt @@ -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,21 +27,32 @@ import com.vitorpamplona.quartz.utils.TimeUtils class VerificationStateCache { private val cache = LruCache(200) + fun verify( + event: OtsEvent, + resolverBuilder: OtsResolverBuilder, + ): VerificationState { + cache.put(event.id, VerificationState.Verifying) + return event.verifyState(resolverBuilder.build()).also { cache.put(event.id, it) } + } + fun cacheVerify( event: OtsEvent, - resolverBuilder: () -> OtsResolver, + resolverBuilder: OtsResolverBuilder, ): VerificationState = when (val verif = cache[event.id]) { + is VerificationState.Verifying -> verif is VerificationState.Verified -> verif is VerificationState.NetworkError -> { // try again in 5 mins if (verif.time < TimeUtils.fiveMinutesAgo()) { - event.verifyState(resolverBuilder()).also { cache.put(event.id, it) } + event.verifyState(resolverBuilder.build()).also { cache.put(event.id, it) } } else { - verif + verify(event, resolverBuilder) } } is VerificationState.Error -> verif - else -> event.verifyState(resolverBuilder()).also { cache.put(event.id, it) } + else -> { + verify(event, resolverBuilder) + } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/ots/Calendar.java b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/ots/Calendar.java index 1784a78dec..49fc52f8be 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/ots/Calendar.java +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/ots/Calendar.java @@ -9,6 +9,7 @@ import com.vitorpamplona.quartz.nip03Timestamp.ots.http.Response; import java.net.URL; import java.util.HashMap; +import java.util.Locale; import java.util.Map; /** @@ -94,7 +95,7 @@ public class Calendar implements ICalendar { headers.put("User-Agent", "java-opentimestamps"); headers.put("Content-Type", "application/x-www-form-urlencoded"); - URL obj = new URL(url + "/timestamp/" + Utils.bytesToHex(commitment).toLowerCase()); + URL obj = new URL(url + "/timestamp/" + Utils.bytesToHex(commitment).toLowerCase(Locale.ROOT)); Request task = new Request(obj); task.setHeaders(headers); Response response = task.call(); diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/ots/Hash.java b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/ots/Hash.java index 939e0a52f7..80d9f42237 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/ots/Hash.java +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/ots/Hash.java @@ -1,5 +1,7 @@ package com.vitorpamplona.quartz.nip03Timestamp.ots; +import androidx.annotation.NonNull; + import com.vitorpamplona.quartz.nip03Timestamp.ots.op.OpCrypto; import com.vitorpamplona.quartz.nip03Timestamp.ots.op.OpKECCAK256; import com.vitorpamplona.quartz.nip03Timestamp.ots.op.OpRIPEMD160; @@ -11,6 +13,7 @@ import java.io.File; import java.io.IOException; import java.io.InputStream; import java.security.NoSuchAlgorithmException; +import java.util.Locale; public class Hash { @@ -121,13 +124,13 @@ public class Hash { * @return The generated com.vitorpamplona.quartz.ots.OpCrypto object. */ public static OpCrypto getOp(String label) { - if (label.toLowerCase().equals(new OpSHA1()._TAG_NAME())) { + if (label.toLowerCase(Locale.ROOT).equals(new OpSHA1()._TAG_NAME())) { return new OpSHA1(); - } else if (label.toLowerCase().equals(new OpSHA256()._TAG_NAME())) { + } else if (label.toLowerCase(Locale.ROOT).equals(new OpSHA256()._TAG_NAME())) { return new OpSHA256(); - } else if (label.toLowerCase().equals(new OpRIPEMD160()._TAG_NAME())) { + } else if (label.toLowerCase(Locale.ROOT).equals(new OpRIPEMD160()._TAG_NAME())) { return new OpRIPEMD160(); - } else if (label.toLowerCase().equals(new OpKECCAK256()._TAG_NAME())) { + } else if (label.toLowerCase(Locale.ROOT).equals(new OpKECCAK256()._TAG_NAME())) { return new OpKECCAK256(); } @@ -187,6 +190,7 @@ public class Hash { * * @return The output. */ + @NonNull @Override public String toString() { String output = "com.vitorpamplona.quartz.ots.Hash\n"; diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/ots/OpenTimestamps.java b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/ots/OpenTimestamps.java index 425003bd0e..809d2f19b6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/ots/OpenTimestamps.java +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/ots/OpenTimestamps.java @@ -20,6 +20,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -63,7 +64,7 @@ public class OpenTimestamps { return "No ots file"; } - String fileHash = Utils.bytesToHex(detachedTimestampFile.timestamp.msg).toLowerCase(); + String fileHash = Utils.bytesToHex(detachedTimestampFile.timestamp.msg).toLowerCase(Locale.ROOT); String hashOp = ((OpCrypto) detachedTimestampFile.fileHashOp)._TAG_NAME(); String firstLine = "File " + hashOp + " hash: " + fileHash + '\n'; @@ -82,7 +83,7 @@ public class OpenTimestamps { return "No timestamp"; } - String fileHash = Utils.bytesToHex(timestamp.msg).toLowerCase(); + String fileHash = Utils.bytesToHex(timestamp.msg).toLowerCase(Locale.ROOT); String firstLine = "Hash: " + fileHash + '\n'; return firstLine + "Timestamp:\n" + timestamp.strTree(0); @@ -270,7 +271,7 @@ public class OpenTimestamps { public HashMap verify(DetachedTimestampFile ots, byte[] diggest) throws Exception { if (!Arrays.equals(ots.fileDigest(), diggest)) { - Log.e("OpenTimestamp", "Expected digest " + Hex.encode(ots.fileDigest()).toLowerCase()); + Log.e("OpenTimestamp", "Expected digest " + Hex.encode(ots.fileDigest()).toLowerCase(Locale.ROOT)); Log.e("OpenTimestamp", "File does not match original!"); throw new Exception("File does not match original!"); } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/ots/Timestamp.java b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/ots/Timestamp.java index 3e424d9a79..eff52a3c28 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/ots/Timestamp.java +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/ots/Timestamp.java @@ -15,6 +15,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Map.Entry; import java.util.Set; @@ -376,7 +377,7 @@ public class Timestamp { if (attestation instanceof BitcoinBlockHeaderAttestation) { String tx = Utils.bytesToHex(Utils.arrayReverse(this.msg)); - builder.append(Timestamp.indention(indent) + "# Bitcoin block merkle root " + tx.toLowerCase() + "\n"); + builder.append(Timestamp.indention(indent) + "# Bitcoin block merkle root " + tx.toLowerCase(Locale.ROOT) + "\n"); } } } @@ -395,7 +396,7 @@ public class Timestamp { curPar = ((OpBinary) op).arg; } - builder.append(Timestamp.indention(indent) + " -> " + op.toString().toLowerCase() + strResult(verbosity, curPar, curRes).toLowerCase() + "\n"); + builder.append(Timestamp.indention(indent) + " -> " + op.toString().toLowerCase(Locale.ROOT) + strResult(verbosity, curPar, curRes).toLowerCase(Locale.ROOT) + "\n"); builder.append(timestamp.strTree(indent + 1, verbosity)); } } else if (this.ops.size() > 0) { @@ -411,7 +412,7 @@ public class Timestamp { curPar = ((OpBinary) op).arg; } - builder.append(Timestamp.indention(indent) + op.toString().toLowerCase() + strResult(verbosity, curPar, curRes).toLowerCase() + "\n"); + builder.append(Timestamp.indention(indent) + op.toString().toLowerCase(Locale.ROOT) + strResult(verbosity, curPar, curRes).toLowerCase(Locale.ROOT) + "\n"); builder.append(timestamp.strTree(indent, verbosity)); } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/ots/Utils.java b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/ots/Utils.java index 97ea90f9c9..fdc2895dd4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/ots/Utils.java +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/ots/Utils.java @@ -2,6 +2,7 @@ package com.vitorpamplona.quartz.nip03Timestamp.ots; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; +import java.util.Locale; import java.util.logging.ConsoleHandler; import java.util.logging.LogRecord; import java.util.logging.Logger; @@ -181,7 +182,7 @@ public class Utils { * @return the string, with its first character converted to uppercase */ public static String toUpperFirstLetter(String string) { - return string.substring(0, 1).toUpperCase() + string.substring(1).toLowerCase(); + return string.substring(0, 1).toUpperCase(Locale.getDefault()) + string.substring(1).toLowerCase(Locale.getDefault()); } // TODO: This is not the way to do logging. Fix later, possibly with slf4j annotation. Need to read up on the subject. diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/ots/VerifyResult.java b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/ots/VerifyResult.java index 4f777de1fe..51b48ab0ba 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/ots/VerifyResult.java +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/ots/VerifyResult.java @@ -1,5 +1,7 @@ package com.vitorpamplona.quartz.nip03Timestamp.ots; +import android.annotation.SuppressLint; + import java.text.DateFormatSymbols; import java.text.SimpleDateFormat; import java.util.Date; @@ -32,6 +34,7 @@ public class VerifyResult implements Comparable { String pattern = "yyyy-MM-dd z"; Locale locale = new Locale("en", "UK"); DateFormatSymbols dateFormatSymbols = new DateFormatSymbols(locale); + @SuppressLint("SimpleDateFormat") SimpleDateFormat simpleDateFormat = new SimpleDateFormat(pattern, dateFormatSymbols); String string = simpleDateFormat.format(new Date(timestamp * 1000)); diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpBinary.java b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpBinary.java index 3d7b5a18dc..5a16b97321 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpBinary.java +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/ots/op/OpBinary.java @@ -9,6 +9,7 @@ import com.vitorpamplona.quartz.nip03Timestamp.ots.exceptions.DeserializationExc import com.vitorpamplona.quartz.utils.Hex; import java.util.Arrays; +import java.util.Locale; /** * Operations that act on a message and a single argument. @@ -56,7 +57,7 @@ public abstract class OpBinary extends Op implements Comparable { @Override public String toString() { - return this._TAG_NAME() + ' ' + Hex.encode(this.arg).toLowerCase(); + return this._TAG_NAME() + ' ' + Hex.encode(this.arg).toLowerCase(Locale.ROOT); } @Override diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/tags/TargetEventKindTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/tags/TargetEventKindTag.kt index 6f2955a3d9..0b2bcb3afd 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/tags/TargetEventKindTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/tags/TargetEventKindTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/tags/TargetEventTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/tags/TargetEventTag.kt index 10b3270b73..6d5f0dd453 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/tags/TargetEventTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/tags/TargetEventTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/PrivateDMCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/PrivateDMCache.kt new file mode 100644 index 0000000000..d1822588b2 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/PrivateDMCache.kt @@ -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.quartz.nip04Dm + +import androidx.collection.LruCache +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.caches.DecryptCache +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent + +class PrivateDMCache( + signer: NostrSigner, +) { + private val decryptionCache = + object : LruCache(10000) { + override fun create(key: PrivateDmEvent): PrivateDMDecryptCache? { + val canDecrypt = key.isIncluded(signer.pubKey) + return if (key.content.isNotBlank() && canDecrypt) { + PrivateDMDecryptCache(signer) + } else { + null + } + } + } + + fun cachedDM(event: PrivateDmEvent): String? = decryptionCache[event]?.cached() + + suspend fun decryptDM(event: PrivateDmEvent) = decryptionCache[event]?.decrypt(event) +} + +class PrivateDMDecryptCache( + signer: NostrSigner, +) : DecryptCache(signer) { + override suspend fun decryptAndParse( + event: PrivateDmEvent, + signer: NostrSigner, + ) = event.decryptContent(signer) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/crypto/AESCBC.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/crypto/AESCBC.kt index 0a2c9b04f7..260782dbf8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/crypto/AESCBC.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/crypto/AESCBC.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/crypto/EncryptedInfo.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/crypto/EncryptedInfo.kt index 067185f209..096b5be660 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/crypto/EncryptedInfo.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/crypto/EncryptedInfo.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/crypto/Encryption.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/crypto/Encryption.kt index 3303c9f2aa..6fce192d1b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/crypto/Encryption.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/crypto/Encryption.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/crypto/Nip04.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/crypto/Nip04.kt index a4d86f7f77..0d717587b5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/crypto/Nip04.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/crypto/Nip04.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/messages/PrivateDmEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/messages/PrivateDmEvent.kt index 3e31478a03..7b7b999790 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/messages/PrivateDmEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/messages/PrivateDmEvent.kt @@ -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 com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.any +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.pTag @@ -37,8 +39,6 @@ import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.utils.bytesUsedInMemory -import com.vitorpamplona.quartz.utils.pointerSizeInBytes import kotlinx.collections.immutable.persistentSetOf @Immutable @@ -50,21 +50,35 @@ class PrivateDmEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), - ChatroomKeyable { - @Transient private var decryptedContent: Map = mapOf() + ChatroomKeyable, + PubKeyHintProvider { + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) - override fun countMemory(): Long = - super.countMemory() + - pointerSizeInBytes + (decryptedContent.values.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() }) + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) override fun isContentEncoded() = true + fun isIncluded(signer: NostrSigner) = isIncluded(signer.pubKey) + + override fun isIncluded(user: HexKey) = pubKey == user || recipientPubKey() == user + + suspend fun decryptContent(signer: NostrSigner): String { + if (!isIncluded(signer.pubKey)) throw SignerExceptions.UnauthorizedDecryptionException() + + val retVal = signer.decrypt(content, talkingWith(signer.pubKey)) + return if (retVal.startsWith(NIP_18_ADVERTISEMENT)) { + retVal.substring(16) + } else { + retVal + } + } + /** * This may or may not be the actual recipient's pub key. The event is intended to look like a * nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used for * initial messages. */ - private fun recipientPubKey() = tags.firstNotNullOfOrNull(PTag::parseKey) + fun recipientPubKey() = tags.firstNotNullOfOrNull(PTag::parseKey) fun recipientPubKeyBytes() = recipientPubKey()?.runCatching { Hex.decode(this) }?.getOrNull() @@ -79,43 +93,12 @@ class PrivateDmEvent( fun talkingWith(oneSideHex: String): HexKey = if (pubKey == oneSideHex) verifiedRecipientPubKey() ?: pubKey else pubKey - override fun chatroomKey(toRemove: String): ChatroomKey = ChatroomKey(persistentSetOf(talkingWith(toRemove))) + override fun chatroomKey(toRemove: HexKey): ChatroomKey = ChatroomKey(persistentSetOf(talkingWith(toRemove))) - /** - * To be fully compatible with nip-04, we read e-tags that are in violation to nip-18. - * - * Nip-18 messages should refer to other events by inline references in the content like - * `[](e/c06f795e1234a9a1aecc731d768d4f3ca73e80031734767067c82d67ce82e506). - */ fun replyTo() = tags.firstNotNullOfOrNull(MarkedETag::parseId) fun with(pubkeyHex: HexKey): Boolean = pubkeyHex == pubKey || tags.any(PTag::isTagged, pubkeyHex) - fun cachedContentFor(signer: NostrSigner): String? = decryptedContent[signer.pubKey] - - fun plainContent( - signer: NostrSigner, - onReady: (String) -> Unit, - ) { - decryptedContent[signer.pubKey]?.let { - onReady(it) - return - } - - signer.decrypt(content, talkingWith(signer.pubKey)) { retVal -> - val content = - if (retVal.startsWith(NIP_18_ADVERTISEMENT)) { - retVal.substring(16) - } else { - retVal - } - - decryptedContent = decryptedContent + Pair(signer.pubKey, content) - - onReady(content) - } - } - companion object { const val KIND = 4 const val ALT = "Private Message" @@ -123,11 +106,11 @@ class PrivateDmEvent( fun prepareMessageToEncrypt( msg: String, - imetas: List? = null, + iMetas: List? = null, advertiseNip18: Boolean = true, ): String { var message = msg - imetas?.forEach { + iMetas?.forEach { message = message.replace(it.url, Nip54InlineMetadata().createUrl(it.url, it.properties)) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/messages/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/messages/TagArrayBuilderExt.kt index b4abcce1a0..e37f0439d9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/messages/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/messages/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05.kt index c97ccf8d81..eb9404e142 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05.kt @@ -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,7 +20,7 @@ */ package com.vitorpamplona.quartz.nip05DnsIdentifiers -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import kotlinx.coroutines.CancellationException class Nip05 { @@ -46,7 +46,7 @@ class Nip05 { // lowercase version of the username. val nip05url = try { - EventMapper.mapper.readTree(returnBody.lowercase()) + JsonMapper.mapper.readTree(returnBody.lowercase()) } catch (e: Throwable) { if (e is CancellationException) throw e return Result.failure(e) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip32SeedDerivation.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip32SeedDerivation.kt index c093a2c453..6a72a0291e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip32SeedDerivation.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip32SeedDerivation.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip39KeyPath.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip39KeyPath.kt index 35bb41ded4..e17d0962ac 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip39KeyPath.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip39KeyPath.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip39Mnemonics.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip39Mnemonics.kt index 11fe2f7a7e..fd68aa7f05 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip39Mnemonics.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip06KeyDerivation/Bip39Mnemonics.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip06KeyDerivation/Nip06.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip06KeyDerivation/Nip06.kt index 6e3fe0d6ed..f6923ea66e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip06KeyDerivation/Nip06.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip06KeyDerivation/Nip06.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip09Deletions/DeletionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip09Deletions/DeletionEvent.kt index 7e79e267ad..96d655716c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip09Deletions/DeletionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip09Deletions/DeletionEvent.kt @@ -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 @@ -55,8 +55,12 @@ class DeletionEvent( AddressHintProvider { override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + fun deleteEvents() = taggedEvents() fun deleteEventIds() = taggedEventIds() @@ -89,6 +93,25 @@ class DeletionEvent( initializer() } + fun buildAddressOnly( + deleteEvents: List, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT) + + deleteEvents.forEach { + if (it is AddressableEvent) { + aTag(it.aTag()) + } + } + + pTagIds(deleteEvents.mapTo(HashSet()) { it.pubKey }) + kinds(deleteEvents.mapTo(HashSet()) { it.kind }) + + initializer() + } + fun buildForVersionOnly( deleteEvents: List, createdAt: Long = TimeUtils.now(), diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/data/DeletionIndex.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip09Deletions/DeletionIndex.kt similarity index 79% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/data/DeletionIndex.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip09Deletions/DeletionIndex.kt index 2fa763348d..7e0e0e1618 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/data/DeletionIndex.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip09Deletions/DeletionIndex.kt @@ -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,12 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.data +package com.vitorpamplona.quartz.nip09Deletions 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.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip01Core.verify +import com.vitorpamplona.quartz.utils.LargeCache class DeletionIndex { data class DeletionRequest( @@ -45,14 +46,24 @@ class DeletionIndex { // Anything newer than the date should not be deleted. private val deletedReferencesBefore = LargeCache() - fun add(event: DeletionEvent): Boolean { + fun add( + event: DeletionEvent, + wasVerified: Boolean, + ): Boolean { var atLeastOne = false + var myWasVerified = wasVerified - event.tags.forEach { - if (it.size > 1 && (it[0] == "a" || it[0] == "e")) { - if (add(it[1], event.pubKey, event)) { - atLeastOne = true - } + event.deleteEventIds().forEach { toDelete -> + if (add(toDelete, event.pubKey, event, myWasVerified)) { + myWasVerified = true + atLeastOne = true + } + } + + event.deleteAddressIds().forEach { toDelete -> + if (add(toDelete, event.pubKey, event, myWasVerified)) { + myWasVerified = true + atLeastOne = true } } @@ -63,13 +74,16 @@ class DeletionIndex { ref: String, byPubKey: HexKey, deletionEvent: DeletionEvent, + wasVerified: Boolean, ): Boolean { val key = DeletionRequest(ref, byPubKey) val previousDeletionEvent = deletedReferencesBefore.get(key) if (previousDeletionEvent == null || deletionEvent.createdAt > previousDeletionEvent.createdAt) { - deletedReferencesBefore.put(key, deletionEvent) - return true + if (wasVerified || deletionEvent.verify()) { + deletedReferencesBefore.put(key, deletionEvent) + return true + } } return false } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/BaseNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/BaseNoteEvent.kt new file mode 100644 index 0000000000..8b02fc2bbb --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/BaseNoteEvent.kt @@ -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.quartz.nip10Notes + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip10Notes.content.findIndexTagsWithEventsOrAddresses +import com.vitorpamplona.quartz.nip10Notes.content.findIndexTagsWithPeople +import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris +import com.vitorpamplona.quartz.nip19Bech32.entities.Entity +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub + +@Immutable +open class BaseNoteEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + @Transient + private var citedUsersCache: Set? = null + + @Transient + private var citedNotesCache: Set? = null + + @Transient + private var citedNIP19Cache: List? = null + + fun citedNIP19(): List { + citedNIP19Cache?.let { + return it + } + + return findNostrUris(content).also { citedNIP19Cache = it } + } + + fun citedUsers(): Set { + citedUsersCache?.let { + return it + } + + val citedUsers = mutableSetOf() + + findIndexTagsWithPeople(content, tags, citedUsers) + citedNIP19().forEach { parsed -> + when (parsed) { + is NProfile -> citedUsers.add(parsed.hex) + is NPub -> citedUsers.add(parsed.hex) + } + } + + citedUsersCache = citedUsers + return citedUsers + } + + fun findCitations(): Set { + citedNotesCache?.let { + return it + } + + val citations = mutableSetOf() + + findIndexTagsWithEventsOrAddresses(content, tags, citations).toMutableSet() + citedNIP19().forEach { entity -> + when (entity) { + is NEvent -> citations.add(entity.hex) + is NAddress -> citations.add(entity.aTag()) + is NNote -> citations.add(entity.hex) + is NEmbed -> citations.add(entity.event.id) + } + } + + citedNotesCache = citations + return citations + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt index 72db040c05..072cf6fbe4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt @@ -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,26 +21,10 @@ package com.vitorpamplona.quartz.nip10Notes import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider -import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider -import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedATags -import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers -import com.vitorpamplona.quartz.nip10Notes.content.findIndexTagsWithEventsOrAddresses -import com.vitorpamplona.quartz.nip10Notes.content.findIndexTagsWithPeople -import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag -import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag -import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress -import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed -import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent -import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile -import com.vitorpamplona.quartz.nip19Bech32.entities.NPub -import com.vitorpamplona.quartz.nip19Bech32.entities.Note import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.utils.lastNotNullOfOrNull @@ -54,16 +38,7 @@ open class BaseThreadedEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, kind, tags, content, sig), - EventHintProvider, - AddressHintProvider, - PubKeyHintProvider { - override fun eventHints() = tags.mapNotNull(MarkedETag::parseAsHint) + tags.mapNotNull(QTag::parseEventAsHint) - - override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + tags.mapNotNull(QTag::parseAddressAsHint) - - override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) - +) : BaseNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) { fun mentions() = taggedUsers() fun markedRoot() = tags.firstNotNullOfOrNull(MarkedETag::parseRoot) @@ -89,16 +64,6 @@ open class BaseThreadedEvent( ?: markedRoot()?.eventId ?: unmarkedReply()?.eventId - /* - Not sure if this is needed - open fun replyingToAddress(): ATag? { - val oldStylePositional = tags.lastOrNull { it.size > 1 && it.size <= 3 && it[0] == "a" }?.let { ATag.parseAtag(it[1], it[2]) } - val newStyleReply = tags.lastOrNull { it.size > 3 && it[0] == "a" && it[3] == "reply" }?.let { ATag.parseAtag(it[1], it[2]) } - val newStyleRoot = tags.lastOrNull { it.size > 3 && it[0] == "a" && it[3] == "root" }?.let { ATag.parseAtag(it[1], it[2]) } - - return newStyleReply ?: newStyleRoot ?: oldStylePositional - }*/ - open fun replyingToAddressOrEvent(): String? { val oldStylePositional = tags.lastOrNull { it.size > 1 && it.size <= 3 && (it[0] == "e" || it[0] == "a") }?.get(1) val newStyleReply = tags.lastOrNull { it.size > 3 && (it[0] == "e" || it[0] == "a") && it[3] == "reply" }?.get(1) @@ -107,50 +72,6 @@ open class BaseThreadedEvent( return newStyleReply ?: newStyleRoot ?: oldStylePositional } - @Transient private var citedUsersCache: Set? = null - - @Transient private var citedNotesCache: Set? = null - - fun citedUsers(): Set { - citedUsersCache?.let { - return it - } - - val citedUsers = mutableSetOf() - - findIndexTagsWithPeople(content, tags, citedUsers) - findNostrUris(content).forEach { parsed -> - when (parsed) { - is NProfile -> citedUsers.add(parsed.hex) - is NPub -> citedUsers.add(parsed.hex) - } - } - - citedUsersCache = citedUsers - return citedUsers - } - - fun findCitations(): Set { - citedNotesCache?.let { - return it - } - - val citations = mutableSetOf() - - findIndexTagsWithEventsOrAddresses(content, tags, citations).toMutableSet() - findNostrUris(content).forEach { entity -> - when (entity) { - is NEvent -> citations.add(entity.hex) - is NAddress -> citations.add(entity.aTag()) - is Note -> citations.add(entity.hex) - is NEmbed -> citations.add(entity.event.id) - } - } - - citedNotesCache = citations - return citations - } - fun tagsWithoutCitations(): List { val certainRepliesTo = markedReplyTos() val uncertainRepliesTo = unmarkedReplyTos() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt index 1c9e743dd5..44d6944669 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt @@ -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,11 +23,29 @@ package com.vitorpamplona.quartz.nip10Notes import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag import com.vitorpamplona.quartz.nip10Notes.tags.markedETags import com.vitorpamplona.quartz.nip10Notes.tags.prepareETagsAsReplyTo +import com.vitorpamplona.quartz.nip14Subject.subject +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip19Bech32.addressHints +import com.vitorpamplona.quartz.nip19Bech32.addressIds +import com.vitorpamplona.quartz.nip19Bech32.eventHints +import com.vitorpamplona.quartz.nip19Bech32.eventIds +import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints +import com.vitorpamplona.quartz.nip19Bech32.pubKeys import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -38,12 +56,64 @@ class TextNoteEvent( tags: Array>, content: String, sig: HexKey, -) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider, + SearchableEvent { + override fun indexableContent() = "Subject: " + subject() + "\n" + content + + override fun eventHints(): List { + val eHints = tags.mapNotNull(MarkedETag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseEventAsHint) + val nip19Hints = citedNIP19().eventHints() + + return eHints + qHints + nip19Hints + } + + override fun linkedEventIds(): List { + val eHints = tags.mapNotNull(MarkedETag::parseId) + val qHints = tags.mapNotNull(QTag::parseEventId) + val nip19Hints = citedNIP19().eventIds() + + return eHints + qHints + nip19Hints + } + + override fun addressHints(): List { + val aHints = tags.mapNotNull(ATag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseAddressAsHint) + val nip19Hints = citedNIP19().addressHints() + + return aHints + qHints + nip19Hints + } + + override fun linkedAddressIds(): List { + val aHints = tags.mapNotNull(ATag::parseAddressId) + val qHints = tags.mapNotNull(QTag::parseAddressId) + val nip19Hints = citedNIP19().addressIds() + + return aHints + qHints + nip19Hints + } + + override fun pubKeyHints(): List { + val pHints = tags.mapNotNull(PTag::parseAsHint) + val nip19Hints = citedNIP19().pubKeyHints() + + return pHints + nip19Hints + } + + override fun linkedPubKeys(): List { + val pHints = tags.mapNotNull(PTag::parseKey) + val nip19Hints = citedNIP19().pubKeys() + + return pHints + nip19Hints + } + companion object { const val KIND = 1 const val ALT = "A short note: " - fun shortedMessageForAlt(msg: String): String { + private fun shortedMessageForAlt(msg: String): String { if (msg.length < 50) return ALT + msg return ALT + msg.take(50) + "..." } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/content/ContentHashTags.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/content/ContentHashTags.kt index d44ca0a57d..e75924a1fc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/content/ContentHashTags.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/content/ContentHashTags.kt @@ -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 @@ -28,6 +28,8 @@ fun findHashtags( content: String, output: MutableSet = mutableSetOf(), ): List { + if (content.isBlank()) return emptyList() + val matcher = hashtagSearch.matcher(content) while (matcher.find()) { try { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/content/IndexedTags.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/content/IndexedTags.kt index 0af01dee06..6b0d007dd8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/content/IndexedTags.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/content/IndexedTags.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/content/NostrUris.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/content/NostrUris.kt index b890406f28..cd948fb3cf 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/content/NostrUris.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/content/NostrUris.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/content/Urls.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/content/Urls.kt index 05b38990f2..6969635099 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/content/Urls.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/content/Urls.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/MarkedETag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/MarkedETag.kt index 15ecd34944..d5b2a85dca 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/MarkedETag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/MarkedETag.kt @@ -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,43 +24,30 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.events.GenericETag import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.utils.arrayOfNotNull -import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.ensure -import com.vitorpamplona.quartz.utils.pointerSizeInBytes @Immutable data class MarkedETag( override val eventId: HexKey, ) : GenericETag { - override var relay: String? = null - var marker: String? = null + override var relay: NormalizedRelayUrl? = null + var marker: MARKER? = null override var author: HexKey? = null - constructor(eventId: HexKey, relayHint: String? = null, marker: String? = null, authorPubKeyHex: HexKey? = null) : this(eventId) { + constructor(eventId: HexKey, relayHint: NormalizedRelayUrl? = null, marker: MARKER? = null, authorPubKeyHex: HexKey? = null) : this(eventId) { this.relay = relayHint this.marker = marker this.author = authorPubKeyHex } - constructor(eventId: HexKey, relayHint: String? = null, marker: MARKER? = null, authorPubKeyHex: HexKey? = null) : this(eventId) { - this.relay = relayHint - this.marker = marker?.code - this.author = authorPubKeyHex - } - - fun countMemory(): Long = - 4 * pointerSizeInBytes + // 3 fields, 4 bytes each reference (32bit) - eventId.bytesUsedInMemory() + - (relay?.bytesUsedInMemory() ?: 0) + - (marker?.bytesUsedInMemory() ?: 0) + - (author?.bytesUsedInMemory() ?: 0) - fun toNEvent(): String = NEvent.create(eventId, author, null, relay) - override fun toTagArray() = arrayOfNotNull(TAG_NAME, eventId, relay, marker, author) + override fun toTagArray() = assemble(eventId, relay, marker, author) enum class MARKER( val code: String, @@ -69,6 +56,18 @@ data class MarkedETag( REPLY("reply"), MENTION("mention"), FORK("fork"), + ; + + companion object { + fun parse(code: String): MARKER? = + when (code) { + ROOT.code -> ROOT + REPLY.code -> REPLY + MENTION.code -> MENTION + FORK.code -> FORK + else -> null + } + } } companion object { @@ -88,67 +87,71 @@ data class MarkedETag( @JvmStatic fun parse(tag: Array): MarkedETag? { - if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + ensure(tag[2].isNotEmpty()) { return null } return MarkedETag( - tag[ORDER_EVT_ID], - tag[ORDER_RELAY], - tag[ORDER_MARKER], - tag.getOrNull( - ORDER_PUBKEY, - ), + eventId = tag[1], + relayHint = pickRelayHint(tag), + marker = pickMarker(tag), + authorPubKeyHex = pickAuthor(tag), ) } @JvmStatic fun parseId(tag: Array): HexKey? { - if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } - return tag[ORDER_EVT_ID] + return tag[1] + } + + // simple case ["e", "id", "relay"] + // empty tags ["e", "id", "relay", ""] + // current root ["e", "id", "relay", "marker"] + // current root ["e", "id", "relay", "marker", "pubkey"] + // empty tags ["e", "id", "relay", "", "pubkey"] + // pubkey marker ["e", "id", "relay", "pubkey"] + // pubkey marker ["e", "id", "relay", "pubkey", "marker"] + // pubkey marker ["e", "id", "pubkey"] // incorrect + // current root ["e", "id", "marker"] // incorrect + + @JvmStatic + private fun pickRelayHint(tag: Array): NormalizedRelayUrl? { + if (tag.has(2) && tag[2].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[2])) return RelayUrlNormalizer.normalizeOrNull(tag[2]) + if (tag.has(3) && tag[3].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[3])) return RelayUrlNormalizer.normalizeOrNull(tag[3]) + if (tag.has(4) && tag[4].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[4])) return RelayUrlNormalizer.normalizeOrNull(tag[4]) + return null + } + + @JvmStatic + private fun pickAuthor(tag: Array): HexKey? { + if (tag.has(3) && tag[3].length == 64) return tag[3] + if (tag.has(4) && tag[4].length == 64) return tag[4] + if (tag.has(2) && tag[2].length == 64) return tag[2] + return null + } + + @JvmStatic + private fun pickMarker(tag: Array): MARKER? { + if (tag.has(3)) MARKER.parse(tag[3])?.let { return it } + if (tag.has(4)) MARKER.parse(tag[4])?.let { return it } + if (tag.has(2)) MARKER.parse(tag[2])?.let { return it } + return null } @JvmStatic fun parseAllThreadTags(tag: Array): MarkedETag? = if (tag.size >= 2 && tag[0] == TAG_NAME) { - if (tag.size <= 3) { - // simple case ["e", "id", "relay"] - MarkedETag(tag[1], tag.getOrNull(2), null as String?, null) - } else if (tag.size == 4) { - if (tag[3].isEmpty()) { - // empty tags ["e", "id", "relay", ""] - MarkedETag(tag[1], tag[2], null as String?, null) - } else if (tag[3].length == 64) { - // updated case with pubkey instead of marker ["e", "id", "relay", "pubkey"] - MarkedETag(tag[1], tag[2], null as String?, tag[3]) - } else if (tag[3] == MARKER.ROOT.code) { - // corrent root ["e", "id", "relay", "root"] - MarkedETag(tag[1], tag[2], tag[3]) - } else if (tag[3] == MARKER.REPLY.code) { - // correct reply ["e", "id", "relay", "reply"] - MarkedETag(tag[1], tag[2], tag[3]) - } else { - // ignore "mention" and "fork" markers - null - } - } else { - // tag.size >= 5 - if (tag[3].isEmpty()) { - // empty tags ["e", "id", "relay", "", "pubkey"] - MarkedETag(tag[1], tag[2], null as String?, tag[4]) - } else if (tag[3].length == 64) { - // updated case with pubkey instead of marker ["e", "id", "relay", "pubkey"] - MarkedETag(tag[1], tag[2], null as String?, tag[3]) - } else if (tag[3] == MARKER.ROOT.code) { - // corrent root ["e", "id", "relay", "root"] - MarkedETag(tag[1], tag[2], tag[3], tag[4]) - } else if (tag[3] == MARKER.REPLY.code) { - // correct reply ["e", "id", "relay", "reply"] - MarkedETag(tag[1], tag[2], tag[3], tag[4]) - } else { - // ignore "mention" and "fork" markers - null - } - } + MarkedETag( + eventId = tag[1], + relayHint = pickRelayHint(tag), + marker = pickMarker(tag), + authorPubKeyHex = pickAuthor(tag), + ) } else { null } @@ -188,22 +191,27 @@ data class MarkedETag( ensure(tag.has(2)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - ensure(tag[2].isNotEmpty()) { return null } - return EventIdHint(tag[1], tag[2]) + + val hint = pickRelayHint(tag) + ensure(hint != null) { return null } + + return EventIdHint(tag[1], hint) } @JvmStatic fun parseRoot(tag: Array): MarkedETag? { - if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null - if (tag[ORDER_MARKER] != MARKER.ROOT.code) return null - // ["e", id hex, relay hint, marker, pubkey] + ensure(tag.has(3)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + + val marker = pickMarker(tag) + ensure(marker == MARKER.ROOT) { return null } + return MarkedETag( - tag[ORDER_EVT_ID], - tag[ORDER_RELAY], - tag[ORDER_MARKER], - tag.getOrNull( - ORDER_PUBKEY, - ), + eventId = tag[1], + relayHint = pickRelayHint(tag), + marker = marker, + authorPubKeyHex = pickAuthor(tag), ) } @@ -213,23 +221,25 @@ data class MarkedETag( @JvmStatic fun parseUnmarkedRoot(tag: Array): MarkedETag? = if (tag.size in 2..3 && tag[0] == TAG_NAME) { - MarkedETag(tag[1], tag.getOrNull(2), MARKER.ROOT) + MarkedETag(tag[1], pickRelayHint(tag), MARKER.ROOT) } else { null } @JvmStatic fun parseReply(tag: Array): MarkedETag? { - if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null - if (tag[ORDER_MARKER] != MARKER.REPLY.code) return null - // ["e", id hex, relay hint, marker, pubkey] + ensure(tag.has(3)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + + val marker = pickMarker(tag) + ensure(marker == MARKER.REPLY) { return null } + return MarkedETag( - tag[ORDER_EVT_ID], - tag[ORDER_RELAY], - tag[ORDER_MARKER], - tag.getOrNull( - ORDER_PUBKEY, - ), + eventId = tag[1], + relayHint = pickRelayHint(tag), + marker = marker, + authorPubKeyHex = pickAuthor(tag), ) } @@ -239,25 +249,37 @@ data class MarkedETag( @JvmStatic fun parseUnmarkedReply(tag: Array): MarkedETag? = if (tag.size in 2..3 && tag[0] == TAG_NAME) { - MarkedETag(tag[1], tag.getOrNull(2), MARKER.REPLY) + MarkedETag(tag[1], pickRelayHint(tag), MARKER.REPLY) } else { null } @JvmStatic fun parseRootId(tag: Array): HexKey? { - if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null - if (tag[ORDER_MARKER] != MARKER.ROOT.code) return null - // ["e", id hex, relay hint, marker, pubkey] - return tag[ORDER_EVT_ID] + ensure(tag.has(3)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + + val marker = pickMarker(tag) + ensure(marker == MARKER.ROOT) { return null } + + return tag[1] } @JvmStatic fun assemble( eventId: HexKey, - relay: String?, + relay: NormalizedRelayUrl?, + marker: String?, + author: HexKey?, + ) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, marker, author) + + @JvmStatic + fun assemble( + eventId: HexKey, + relay: NormalizedRelayUrl?, marker: MARKER?, author: HexKey?, - ) = arrayOfNotNull(TAG_NAME, eventId, relay, marker?.code, author) + ) = assemble(eventId, relay, marker?.code, author) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/Positional.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/Positional.kt index d21e0d3414..91884e3397 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/Positional.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/Positional.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/ReplyBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/ReplyBuilder.kt index 62da19c06f..3f77f66788 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/ReplyBuilder.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/ReplyBuilder.kt @@ -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 @@ -54,7 +54,7 @@ fun prepareETagsAsReplyTo( val branchTags = replyingTo.event.threadTags().filter { it.eventId != rootTag.eventId }.map { - MarkedETag(it.eventId, it.relay, "", it.author) + MarkedETag(it.eventId, it.relay, null, it.author) } val branch = mutableListOf() @@ -83,7 +83,7 @@ fun prepareMarkedETagsAsReplyTo(replyingTo: EventHintBun val branchTags = replyingTo.event.threadTags().filter { it.eventId != rootTag.eventId }.map { - MarkedETag(it.eventId, it.relay, "", it.author) + MarkedETag(it.eventId, it.relay, null, it.author) } val branch = mutableListOf() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/TagArrayBuilderExt.kt index 75d0088f27..acd905d453 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt index 95ed42153a..cccbde429b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt @@ -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,29 +26,31 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper @Stable class Nip11RelayInformation( - val id: String?, - val name: String?, - val description: String?, - val icon: String?, - val pubkey: String?, - val contact: String?, - val supported_nips: List?, - val supported_nip_extensions: List?, - val software: String?, - val version: String?, - val limitation: RelayInformationLimitation?, - val relay_countries: List?, - val language_tags: List?, - val tags: List?, - val posting_policy: String?, - val payments_url: String?, - val retention: List?, - val fees: RelayInformationFees?, - val nip50: List?, + val id: String? = null, + val name: String? = null, + val description: String? = null, + val icon: String? = null, + val pubkey: String? = null, + val contact: String? = null, + val supported_nips: List? = null, + val supported_nip_extensions: List? = null, + val software: String? = null, + val version: String? = null, + val limitation: RelayInformationLimitation? = null, + val relay_countries: List? = null, + val language_tags: List? = null, + val tags: List? = null, + val posting_policy: String? = null, + val payments_url: String? = null, + val retention: List? = null, + val fees: RelayInformationFees? = null, + val nip50: List? = null, ) { companion object { val mapper = - jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + jacksonObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true) fun fromJson(json: String): Nip11RelayInformation = mapper.readValue(json, Nip11RelayInformation::class.java) } @@ -56,37 +58,37 @@ class Nip11RelayInformation( @Stable class RelayInformationFee( - val amount: Int?, - val unit: String?, - val period: Int?, - val kinds: List?, + val amount: Int? = null, + val unit: String? = null, + val period: Int? = null, + val kinds: List? = null, ) class RelayInformationFees( - val admission: List?, - val subscription: List?, - val publication: List?, + val admission: List? = null, + val subscription: List? = null, + val publication: List? = null, ) class RelayInformationLimitation( - val max_message_length: Int?, - val max_subscriptions: Int?, - val max_filters: Int?, - val max_limit: Int?, - val max_subid_length: Int?, - val min_prefix: Int?, - val max_event_tags: Int?, - val max_content_length: Int?, - val min_pow_difficulty: Int?, - val auth_required: Boolean?, - val payment_required: Boolean?, - val restricted_writes: Boolean?, - val created_at_lower_limit: Int?, - val created_at_upper_limit: Int?, + val max_message_length: Int? = null, + val max_subscriptions: Int? = null, + val max_filters: Int? = null, + val max_limit: Int? = null, + val max_subid_length: Int? = null, + val min_prefix: Int? = null, + val max_event_tags: Int? = null, + val max_content_length: Int? = null, + val min_pow_difficulty: Int? = null, + val auth_required: Boolean? = null, + val payment_required: Boolean? = null, + val restricted_writes: Boolean? = null, + val created_at_lower_limit: Int? = null, + val created_at_upper_limit: Int? = null, ) class RelayInformationRetentionData( - val kinds: ArrayList?, - val tiem: Int?, - val count: Int?, + val kinds: ArrayList? = null, + val tiem: Int? = null, + val count: Int? = null, ) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/EventExt.kt index 1a7b858a36..661ad1a09e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/EventExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/TagArrayBuilderExt.kt index 16b41bc660..980e44191c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/TagArrayExt.kt index e8e39f507f..9e20a9a80e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/TagArrayExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/miner/ByteArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/miner/ByteArrayExt.kt index f931ecdebf..51f626b282 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/miner/ByteArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/miner/ByteArrayExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/miner/MiningBuffer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/miner/MiningBuffer.kt index ac5135e57f..a449b48a03 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/miner/MiningBuffer.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/miner/MiningBuffer.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/miner/PoWMiner.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/miner/PoWMiner.kt index bd3984e9e3..7dd033dcdd 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/miner/PoWMiner.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/miner/PoWMiner.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/miner/PoWRankEvaluator.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/miner/PoWRankEvaluator.kt index 5b521b180c..61396fe8f4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/miner/PoWRankEvaluator.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/miner/PoWRankEvaluator.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/tags/PoWTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/tags/PoWTag.kt index 8b26743248..e194946106 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/tags/PoWTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip13Pow/tags/PoWTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip14Subject/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip14Subject/EventExt.kt index 88f70fd585..a734c0eb0c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip14Subject/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip14Subject/EventExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip14Subject/SubjectTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip14Subject/SubjectTag.kt index 4d8becbb8a..e6ff0b8b83 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip14Subject/SubjectTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip14Subject/SubjectTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip14Subject/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip14Subject/TagArrayBuilderExt.kt index 816eebc084..af9caf6b73 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip14Subject/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip14Subject/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip14Subject/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip14Subject/TagArrayExt.kt index 0300d80fe6..7da7b0db59 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip14Subject/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip14Subject/TagArrayExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt index 0ad78c9ea6..fa116d6793 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt @@ -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 @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.utils.mapNotNullAsync class NIP17Factory { data class Result( @@ -38,122 +39,82 @@ class NIP17Factory { val wraps: List, ) - private fun recursiveGiftWrapCreation( - event: Event, - remainingTos: List, - signer: NostrSigner, - output: MutableList, - onReady: (List) -> Unit, - ) { - if (remainingTos.isEmpty()) { - onReady(output) - return - } - - val next = remainingTos.first() - - SealedRumorEvent.create( - event = event, - encryptTo = next, - signer = signer, - ) { seal -> - GiftWrapEvent.create( - event = seal, - recipientPubKey = next, - ) { giftWrap -> - output.add(giftWrap) - recursiveGiftWrapCreation(event, remainingTos.minus(next), signer, output, onReady) - } - } - } - - private fun createWraps( + private suspend fun createWraps( event: Event, to: Set, signer: NostrSigner, - onReady: (List) -> Unit, - ) { - val wraps = mutableListOf() - recursiveGiftWrapCreation(event, to.toList(), signer, wraps, onReady) - } + ): List = + mapNotNullAsync( + to.toList(), + ) { next -> + GiftWrapEvent.create( + event = + SealedRumorEvent.create( + event = event, + encryptTo = next, + signer = signer, + ), + recipientPubKey = next, + ) + } - fun createMessageNIP17( + suspend fun createMessageNIP17( template: EventTemplate, signer: NostrSigner, - onReady: (Result) -> Unit, - ) { - signer.sign(template) { senderMessage -> - createWraps(senderMessage, senderMessage.groupMembers(), signer) { wraps -> - onReady( - Result( - msg = senderMessage, - wraps = wraps, - ), - ) - } - } + ): Result { + val senderMessage = signer.sign(template) + val wraps = createWraps(senderMessage, senderMessage.groupMembers(), signer) + return Result( + msg = senderMessage, + wraps = wraps, + ) } - fun createEncryptedFileNIP17( + suspend fun createEncryptedFileNIP17( template: EventTemplate, signer: NostrSigner, - onReady: (Result) -> Unit, - ) { - signer.sign(template) { senderMessage -> - createWraps(senderMessage, senderMessage.groupMembers(), signer) { wraps -> - onReady( - Result( - msg = senderMessage, - wraps = wraps, - ), - ) - } - } + ): Result { + val senderMessage = signer.sign(template) + val wraps = createWraps(senderMessage, senderMessage.groupMembers(), signer) + + return Result( + msg = senderMessage, + wraps = wraps, + ) } - fun createReactionWithinGroup( + suspend fun createReactionWithinGroup( content: String, originalNote: EventHintBundle, to: List, signer: NostrSigner, - onReady: (Result) -> Unit, - ) { + ): Result { val senderPublicKey = signer.pubKey + val template = ReactionEvent.build(content, originalNote) - signer.sign( - ReactionEvent.build(content, originalNote), - ) { senderReaction -> - createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer) { wraps -> - onReady( - Result( - msg = senderReaction, - wraps = wraps, - ), - ) - } - } + val senderReaction = signer.sign(template) + val wraps = createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer) + return Result( + msg = senderReaction, + wraps = wraps, + ) } - fun createReactionWithinGroup( + suspend fun createReactionWithinGroup( emojiUrl: EmojiUrlTag, originalNote: EventHintBundle, to: List, signer: NostrSigner, - onReady: (Result) -> Unit, - ) { + ): Result { val senderPublicKey = signer.pubKey + val template = ReactionEvent.build(emojiUrl, originalNote) - signer.sign( - ReactionEvent.build(emojiUrl, originalNote), - ) { senderReaction -> - createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer) { wraps -> - onReady( - Result( - msg = senderReaction, - wraps = wraps, - ), - ) - } - } + val senderReaction = signer.sign(template) + val wraps = createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer) + + return Result( + msg = senderReaction, + wraps = wraps, + ) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/BaseDMGroupEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/BaseDMGroupEvent.kt index 975328dd79..b47e875ceb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/BaseDMGroupEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/BaseDMGroupEvent.kt @@ -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,6 +22,7 @@ package com.vitorpamplona.quartz.nip17Dm.base import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.any import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent @@ -42,6 +43,8 @@ open class BaseDMGroupEvent( PubKeyHintProvider { override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + /** Recipients intended to receive this conversation */ fun recipients() = tags.mapNotNull(PTag::parse) @@ -66,6 +69,8 @@ open class BaseDMGroupEvent( return result } + override fun isIncluded(pubKey: HexKey) = pubKey == this.pubKey || tags.any(PTag::isTagged, pubKey) + override fun groupMembers() = recipientsPubKey().plus(pubKey).toSet() override fun chatroomKey(toRemove: String): ChatroomKey = ChatroomKey(talkingWith(toRemove).toImmutableSet()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/ChatroomKey.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/ChatroomKey.kt index 7a799c81fb..fd45910be9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/ChatroomKey.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/ChatroomKey.kt @@ -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,4 +26,6 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey @Stable data class ChatroomKey( val users: Set, -) +) : Comparable { + override fun compareTo(other: ChatroomKey): Int = users.hashCode().compareTo(other.users.hashCode()) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/ChatroomKeyable.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/ChatroomKeyable.kt index b82ac3684b..6000f7f66c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/ChatroomKeyable.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/ChatroomKeyable.kt @@ -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,7 +21,10 @@ package com.vitorpamplona.quartz.nip17Dm.base import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.IEvent + +interface ChatroomKeyable : IEvent { + fun isIncluded(user: HexKey): Boolean -interface ChatroomKeyable { fun chatroomKey(toRemove: HexKey): ChatroomKey } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/NIP17Group.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/NIP17Group.kt index 07dd6f3d73..0beca6658c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/NIP17Group.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/base/NIP17Group.kt @@ -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,5 +23,7 @@ package com.vitorpamplona.quartz.nip17Dm.base import com.vitorpamplona.quartz.nip01Core.core.HexKey interface NIP17Group { + fun isIncluded(pubKey: HexKey): Boolean + fun groupMembers(): Set } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/ChatMessageEncryptedFileHeaderEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/ChatMessageEncryptedFileHeaderEvent.kt index 13ebd81fef..fb11f2ff66 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/ChatMessageEncryptedFileHeaderEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/ChatMessageEncryptedFileHeaderEvent.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/TagArrayBuilderExt.kt index 4ea99d0601..210becd6de 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/encryption/AESGCM.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/encryption/AESGCM.kt index 391cda1204..a9ff7b419f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/encryption/AESGCM.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/encryption/AESGCM.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/encryption/NostrCipher.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/encryption/NostrCipher.kt index d01ff733ca..0f94fa8ac3 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/encryption/NostrCipher.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/encryption/NostrCipher.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/tags/EncryptionAlgo.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/tags/EncryptionAlgo.kt index 8fe35ea2d3..b7dce56743 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/tags/EncryptionAlgo.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/tags/EncryptionAlgo.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/tags/EncryptionKey.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/tags/EncryptionKey.kt index 6dbfdf91bf..935ed37aa8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/tags/EncryptionKey.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/tags/EncryptionKey.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/tags/EncryptionNonce.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/tags/EncryptionNonce.kt index 1a792a3df0..f311808246 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/tags/EncryptionNonce.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/tags/EncryptionNonce.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/tags/FileTypeTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/tags/FileTypeTag.kt index 3cae4c9d3e..d7a743a72b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/tags/FileTypeTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/tags/FileTypeTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/messages/ChatMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/messages/ChatMessageEvent.kt index cf9292b353..6293e094c5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/messages/ChatMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/messages/ChatMessageEvent.kt @@ -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 @@ -28,7 +28,6 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent -import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent.Companion.ALT_DESCRIPTION import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils @@ -45,7 +44,7 @@ class ChatMessageEvent( companion object { const val KIND = 14 - const val ALT = "Direct message" + const val ALT_DESCRIPTION = "Direct message" fun build( msg: String, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/messages/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/messages/TagArrayBuilderExt.kt index 142a18c1d6..6f50623dc0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/messages/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/messages/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/settings/ChatMessageRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/settings/ChatMessageRelayListEvent.kt index 300c2b19d8..8c44924c2c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/settings/ChatMessageRelayListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/settings/ChatMessageRelayListEvent.kt @@ -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,10 +23,12 @@ package com.vitorpamplona.quartz.nip17Dm.settings import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip17Dm.settings.tags.RelayTag import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils @@ -39,14 +41,7 @@ class ChatMessageRelayListEvent( content: String, sig: HexKey, ) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun relays(): List = - tags.mapNotNull { - if (it.size > 1 && it[0] == "relay") { - it[1] - } else { - null - } - } + fun relays(): List = tags.mapNotNull(RelayTag::parse) companion object { const val KIND = 10050 @@ -57,54 +52,41 @@ class ChatMessageRelayListEvent( fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG) - fun createTagArray(relays: List): Array> = + fun createTagArray(relays: List): Array> = relays - .map { - arrayOf("relay", it) - }.plusElement(AltTag.assemble("Relay list to receive private messages")) - .toTypedArray() + .map { RelayTag.assemble(it) } + .plusElement( + AltTag.assemble("Relay list to receive private messages"), + ).toTypedArray() - fun updateRelayList( + suspend fun updateRelayList( earlierVersion: ChatMessageRelayListEvent, - relays: List, + relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (ChatMessageRelayListEvent) -> Unit, - ) { + ): ChatMessageRelayListEvent { val tags = earlierVersion.tags - .filter { it[0] != "relay" } + .filter(RelayTag::notMatch) .plus( relays.map { - arrayOf("relay", it) + RelayTag.assemble(it) }, ).toTypedArray() - signer.sign(createdAt, KIND, tags, earlierVersion.content, onReady) + return signer.sign(createdAt, KIND, tags, earlierVersion.content) } - fun createFromScratch( - relays: List, + suspend fun create( + relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (ChatMessageRelayListEvent) -> Unit, - ) { - create(relays, signer, createdAt, onReady) - } + ): ChatMessageRelayListEvent = signer.sign(createdAt, KIND, createTagArray(relays), "") fun create( - relays: List, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChatMessageRelayListEvent) -> Unit, - ) { - signer.sign(createdAt, KIND, createTagArray(relays), "", onReady) - } - - fun create( - relays: List, + relays: List, signer: NostrSignerSync, createdAt: Long = TimeUtils.now(), - ): ChatMessageRelayListEvent? = signer.sign(createdAt, KIND, createTagArray(relays), "") + ): ChatMessageRelayListEvent = signer.sign(createdAt, KIND, createTagArray(relays), "") } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/settings/tags/RelayTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/settings/tags/RelayTag.kt new file mode 100644 index 0000000000..d1c50cf20a --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/settings/tags/RelayTag.kt @@ -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.quartz.nip17Dm.settings.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost +import com.vitorpamplona.quartz.utils.ensure + +class RelayTag { + companion object { + const val TAG_NAME = "relay" + + @JvmStatic + fun match(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun notMatch(tag: Array) = tag.has(0) && tag[0] == TAG_NAME + + @JvmStatic + fun parse(tag: Array): NormalizedRelayUrl? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + val relay = RelayUrlNormalizer.normalizeOrNull(tag[1]) + + ensure(relay != null && !relay.isLocalHost()) { return null } + + return relay + } + + @JvmStatic + fun assemble(relay: NormalizedRelayUrl) = arrayOf(TAG_NAME, relay.url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/GenericRepostEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/GenericRepostEvent.kt index 581e66a5d8..acf7e2b1b0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/GenericRepostEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/GenericRepostEvent.kt @@ -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 @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag @@ -40,6 +41,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.pTag import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.lastNotNullOfOrNull @Immutable class GenericRepostEvent( @@ -55,22 +57,28 @@ class GenericRepostEvent( AddressHintProvider { override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) - fun boostedEvents() = tags.mapNotNull(ETag::parse) + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) - fun boostedATags() = tags.mapNotNull(ATag::parse) + fun boostedEvent() = tags.lastNotNullOfOrNull(ETag::parse) - fun boostedAddresses() = tags.mapNotNull(ATag::parseAddress) + fun boostedATag() = tags.lastNotNullOfOrNull(ATag::parse) + + fun boostedAddress() = tags.lastNotNullOfOrNull(ATag::parseAddress) + + fun boostedEventId() = tags.lastNotNullOfOrNull(ETag::parseId) + + fun boostedAddressIds() = tags.lastNotNullOfOrNull(ATag::parseAddressId) fun originalAuthors() = tags.mapNotNull(PTag::parse) - fun boostedEventIds() = tags.mapNotNull(ETag::parseId) - - fun boostedAddressIds() = tags.mapNotNull(ATag::parseAddressId) - fun originalAuthorKeys() = tags.mapNotNull(PTag::parseKey) fun containedPost() = @@ -86,8 +94,8 @@ class GenericRepostEvent( fun build( boostedPost: Event, - eventSourceRelay: String?, - authorHomeRelay: String?, + eventSourceRelay: NormalizedRelayUrl?, + authorHomeRelay: NormalizedRelayUrl?, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, boostedPost.toJson(), createdAt) { @@ -103,12 +111,11 @@ class GenericRepostEvent( initializer() } - fun create( + suspend fun create( boostedPost: Event, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (GenericRepostEvent) -> Unit, - ) { + ): GenericRepostEvent { val content = boostedPost.toJson() val tags = @@ -124,7 +131,7 @@ class GenericRepostEvent( tags.add(arrayOf("k", "${boostedPost.kind}")) tags.add(AltTag.assemble(ALT)) - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + return signer.sign(createdAt, KIND, tags.toTypedArray(), content) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/RepostEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/RepostEvent.kt index 691ec35c60..c7feb946ba 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/RepostEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/RepostEvent.kt @@ -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 @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.aTag @@ -38,6 +39,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.pTag import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.lastNotNullOfOrNull @Immutable class RepostEvent( @@ -53,22 +55,28 @@ class RepostEvent( AddressHintProvider { override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) - fun boostedEvents() = tags.mapNotNull(ETag::parse) + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) - fun boostedATags() = tags.mapNotNull(ATag::parse) + fun boostedEvent() = tags.lastNotNullOfOrNull(ETag::parse) - fun boostedAddresses() = tags.mapNotNull(ATag::parseAddress) + fun boostedATag() = tags.lastNotNullOfOrNull(ATag::parse) + + fun boostedAddress() = tags.lastNotNullOfOrNull(ATag::parseAddress) + + fun boostedEventId() = tags.lastNotNullOfOrNull(ETag::parseId) + + fun boostedAddressIds() = tags.lastNotNullOfOrNull(ATag::parseAddressId) fun originalAuthors() = tags.mapNotNull(PTag::parse) - fun boostedEventIds() = tags.mapNotNull(ETag::parseId) - - fun boostedAddressIds() = tags.mapNotNull(ATag::parseAddressId) - fun originalAuthorKeys() = tags.mapNotNull(PTag::parseKey) fun containedPost() = @@ -84,8 +92,8 @@ class RepostEvent( fun build( boostedPost: Event, - eventSourceRelay: String?, - authorHomeRelay: String?, + eventSourceRelay: NormalizedRelayUrl?, + authorHomeRelay: NormalizedRelayUrl?, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, boostedPost.toJson(), createdAt) { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/EntityExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/EntityExt.kt index eb2d964f51..927eb0c872 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/EntityExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/EntityExt.kt @@ -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,12 +21,14 @@ package com.vitorpamplona.quartz.nip18Reposts.quotes import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent -import com.vitorpamplona.quartz.nip19Bech32.entities.Note +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote -fun Note.toQuoteTag() = QEventTag(hex, null, null) +fun NNote.toQuoteTag() = QEventTag(hex, null, null) fun NEvent.toQuoteTag() = QEventTag(hex, relay.firstOrNull(), author) @@ -39,7 +41,7 @@ fun NEmbed.toQuoteTag() = QEventTag(event.id, null, event.pubKey) } -fun Note.toQuoteTagArray() = QEventTag.assemble(hex, null, null) +fun NNote.toQuoteTagArray() = QEventTag.assemble(hex, null, null) fun NEvent.toQuoteTagArray() = QEventTag.assemble(hex, relay.firstOrNull(), author) @@ -51,3 +53,7 @@ fun NEmbed.toQuoteTagArray() = } else { QEventTag.assemble(event.id, null, event.pubKey) } + +fun ETag.toQTagArray() = QEventTag.assemble(eventId, relay, author) + +fun ATag.toQTagArray() = QAddressableTag.assemble(kind, pubKeyHex, dTag, relay) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/EventExt.kt index f499b58069..7c02568fc5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/EventExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QAddressableTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QAddressableTag.kt index cc78b6f5d0..b25b6ebfa7 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QAddressableTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QAddressableTag.kt @@ -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,6 +23,8 @@ package com.vitorpamplona.quartz.nip18Reposts.quotes import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.bytesUsedInMemory @@ -33,11 +35,11 @@ import com.vitorpamplona.quartz.utils.pointerSizeInBytes data class QAddressableTag( val address: Address, ) : QTag { - var relay: String? = null + var relay: NormalizedRelayUrl? = null constructor( address: Address, - relayHint: String?, + relayHint: NormalizedRelayUrl?, ) : this(address) { this.relay = relayHint } @@ -46,7 +48,7 @@ data class QAddressableTag( kind: Int, pubKeyHex: HexKey, dTag: String, - relayHint: String?, + relayHint: NormalizedRelayUrl?, ) : this(Address(kind, pubKeyHex, dTag)) { this.relay = relayHint } @@ -54,7 +56,7 @@ data class QAddressableTag( fun countMemory(): Long = 2 * pointerSizeInBytes + address.countMemory() + - (relay?.bytesUsedInMemory() ?: 0) + (relay?.url?.bytesUsedInMemory() ?: 0) override fun toTagArray() = assemble(address, relay) @@ -67,7 +69,8 @@ data class QAddressableTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length != 64) { return null } val address = Address.parse(tag[1]) ?: return null - return QAddressableTag(address, tag.getOrNull(2)) + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + return QAddressableTag(address, hint) } @JvmStatic @@ -75,13 +78,13 @@ data class QAddressableTag( kind: Int, pubKeyHex: HexKey, dTag: String, - relay: String?, - ) = arrayOfNotNull(TAG_NAME, Address.assemble(kind, pubKeyHex, dTag), relay) + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, Address.assemble(kind, pubKeyHex, dTag), relay?.url) @JvmStatic fun assemble( address: Address, - relay: String?, - ) = arrayOfNotNull(TAG_NAME, address.toValue(), relay) + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, address.toValue(), relay?.url) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QEventTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QEventTag.kt index 995890fd01..662c6f9b91 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QEventTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QEventTag.kt @@ -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,6 +23,8 @@ package com.vitorpamplona.quartz.nip18Reposts.quotes import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.ensure @@ -32,10 +34,10 @@ import com.vitorpamplona.quartz.utils.pointerSizeInBytes data class QEventTag( val eventId: HexKey, ) : QTag { - var relay: String? = null + var relay: NormalizedRelayUrl? = null var author: HexKey? = null - constructor(eventId: HexKey, relayHint: String? = null, authorPubKeyHex: HexKey? = null) : this(eventId) { + constructor(eventId: HexKey, relayHint: NormalizedRelayUrl? = null, authorPubKeyHex: HexKey? = null) : this(eventId) { this.relay = relayHint this.author = authorPubKeyHex } @@ -43,7 +45,7 @@ data class QEventTag( fun countMemory(): Long = 3 * pointerSizeInBytes + // 3 fields, 4 bytes each reference (32bit) eventId.bytesUsedInMemory() + - (relay?.bytesUsedInMemory() ?: 0) + + (relay?.url?.bytesUsedInMemory() ?: 0) + (author?.bytesUsedInMemory() ?: 0) override fun toTagArray() = assemble(eventId, relay, author) @@ -56,14 +58,17 @@ data class QEventTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return QEventTag(tag[1], tag.getOrNull(2), tag.getOrNull(3)) + + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return QEventTag(tag[1], hint, tag.getOrNull(3)) } @JvmStatic fun assemble( eventId: HexKey, - relay: String?, + relay: NormalizedRelayUrl?, author: HexKey?, - ) = arrayOfNotNull(TAG_NAME, eventId, relay, author) + ) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, author) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QTag.kt index 5feeab571f..14c74e3afe 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QTag.kt @@ -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,9 +20,12 @@ */ package com.vitorpamplona.quartz.nip18Reposts.quotes +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.utils.ensure @@ -36,31 +39,77 @@ interface QTag { fun parse(tag: Array): QTag? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } + + val relayHint = pickRelayHint(tag) + return if (tag[1].length == 64) { - QEventTag(tag[1], tag.getOrNull(2), tag.getOrNull(3)) + QEventTag(tag[1], relayHint, pickAuthor(tag)) } else { val address = Address.parse(tag[1]) ?: return null - QAddressableTag(address, tag.getOrNull(2)) + QAddressableTag(address, relayHint) } } + private fun pickRelayHint(tag: Array): NormalizedRelayUrl? { + if (tag.has(2) && tag[2].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[2])) return RelayUrlNormalizer.normalizeOrNull(tag[2]) + if (tag.has(3) && tag[3].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[3])) return RelayUrlNormalizer.normalizeOrNull(tag[3]) + return null + } + + private fun pickAuthor(tag: Array): HexKey? { + if (tag.has(2) && tag[2].length == 64) return tag[2] + if (tag.has(3) && tag[3].length == 64) return tag[3] + return null + } + + @JvmStatic + fun parseId(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return tag[1] + } + + @JvmStatic + fun parseEventId(tag: Array): HexKey? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + return tag[1] + } + @JvmStatic fun parseEventAsHint(tag: Array): EventIdHint? { - ensure(tag.has(1)) { return null } + ensure(tag.has(2)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } ensure(tag[2].isNotEmpty()) { return null } - return EventIdHint(tag[1], tag[2]) + + val relayHint = pickRelayHint(tag) + ensure(relayHint != null) { return null } + + return EventIdHint(tag[1], relayHint) + } + + fun parseAddressId(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length != 64) { return null } + ensure(!tag[1].contains(':')) { return null } + return tag[1] } @JvmStatic fun parseAddressAsHint(tag: Array): AddressHint? { - ensure(tag.has(1)) { return null } + ensure(tag.has(2)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length != 64) { return null } ensure(tag[2].isNotEmpty()) { return null } ensure(!tag[1].contains(':')) { return null } - return AddressHint(tag[1], tag[2]) + + val relayHint = pickRelayHint(tag) + ensure(relayHint != null) { return null } + + return AddressHint(tag[1], relayHint) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt index 0017af7e01..74aea66ea6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt @@ -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,9 +27,9 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.Entity import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub -import com.vitorpamplona.quartz.nip19Bech32.entities.Note fun TagArrayBuilder.quote(tag: QTag) = add(tag.toTagArray()) @@ -37,7 +37,7 @@ fun TagArrayBuilder.quotes(tag: List) = addAll(tag.map { it fun TagArrayBuilder.quote(entity: Entity) = when (entity) { - is Note -> add(entity.toQuoteTagArray()) + is NNote -> add(entity.toQuoteTagArray()) is NEvent -> add(entity.toQuoteTagArray()) is NAddress -> add(entity.toQuoteTagArray()) is NEmbed -> add(entity.toQuoteTagArray()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayExt.kt index 3b9163da7e..84a46fcc79 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayExt.kt @@ -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,15 +25,14 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.forEachTagged import com.vitorpamplona.quartz.nip01Core.core.isTagged import com.vitorpamplona.quartz.nip01Core.core.mapValueTagged -import com.vitorpamplona.quartz.nip01Core.core.mapValues fun TagArray.forEachTaggedQuoteId(onEach: (eventId: HexKey) -> Unit) = this.forEachTagged(QTag.TAG_NAME, onEach) fun TagArray.mapTaggedQuoteId(map: (eventId: HexKey) -> R) = this.mapValueTagged(QTag.TAG_NAME, map) -fun TagArray.taggedQuotes() = this.mapNotNull(QTag.Companion::parse) +fun TagArray.taggedQuotes() = this.mapNotNull(QTag::parse) -fun TagArray.taggedQuoteIds() = this.mapValues(QTag.TAG_NAME) +fun TagArray.taggedQuoteIds() = this.mapNotNull(QTag::parseId) fun TagArray.firstTaggedQuote() = this.firstNotNullOfOrNull(QTag.Companion::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/ATagExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/ATagExt.kt index 67f471930e..dc9bb9d641 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/ATagExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/ATagExt.kt @@ -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,6 +21,8 @@ package com.vitorpamplona.quartz.nip19Bech32 import android.util.Log +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.utils.Hex @@ -44,7 +46,10 @@ fun ATag.Companion.parseAtag( try { val parts = atag.split(":", limit = 3) Hex.decode(parts[1]) - ATag(parts[0].toInt(), parts[1], parts[2], relay) + + val relayHint = relay?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + ATag(parts[0].toInt(), parts[1], parts[2], relayHint) } catch (t: Throwable) { Log.w("ATag", "Error parsing A Tag: $atag: ${t.message}") null @@ -58,7 +63,7 @@ fun ATag.Companion.parseAtagUnckecked(atag: String): ATag? = null } -fun ATag.toNAddr(overrideRelay: String? = relay): String = NAddress.create(kind, pubKeyHex, dTag, overrideRelay ?: relay) +fun ATag.toNAddr(overrideRelay: NormalizedRelayUrl? = relay): String = NAddress.create(kind, pubKeyHex, dTag, overrideRelay ?: relay) fun ATag.Companion.parseNAddr(naddr: String) = NAddress.parse(naddr)?.let { result -> diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/ByteArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/ByteArrayExt.kt index 1d1fa6bfaa..712103b2d5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/ByteArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/ByteArrayExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/EventExt.kt index 4bf2ce9284..8d96412d4a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/EventExt.kt @@ -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,10 +22,11 @@ package com.vitorpamplona.quartz.nip19Bech32 import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent -fun Event.toNIP19(relayHint: String? = null): String = +fun Event.toNIP19(relayHint: NormalizedRelayUrl? = null): String = if (this is AddressableEvent) { ATag(kind, pubKey, dTag(), relayHint).toNAddr() } else { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/ListEntityExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/ListEntityExt.kt new file mode 100644 index 0000000000..ee64c350ea --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/ListEntityExt.kt @@ -0,0 +1,97 @@ +/** + * 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.quartz.nip19Bech32 + +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip19Bech32.entities.Entity +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.nip19Bech32.entities.NSec + +fun NEvent.toEventHint() = relay.map { EventIdHint(hex, it) } + +fun NAddress.toAddressHint() = relay.map { AddressHint(aTag(), it) } + +fun NProfile.toPubKeyHint() = relay.map { PubKeyHint(hex, it) } + +fun List.eventHints(): List = + mapNotNull { entity -> + if (entity is NEvent) { + entity.toEventHint() + } else { + null + } + }.flatten() + +fun List.eventIds(): List = + mapNotNull { entity -> + when (entity) { + is NEvent -> entity.hex + is NNote -> entity.hex + is NEmbed -> entity.event.id + else -> null + } + } + +fun List.addressHints(): List = + mapNotNull { entity -> + if (entity is NAddress) { + entity.toAddressHint() + } else { + null + } + }.flatten() + +fun List.addressIds(): List = + mapNotNull { entity -> + when (entity) { + is NAddress -> entity.aTag() + is NEmbed -> if (entity.event is AddressableEvent) entity.event.addressTag() else null + else -> null + } + } + +fun List.pubKeyHints(): List = + mapNotNull { entity -> + if (entity is NProfile) { + entity.relay.map { PubKeyHint(entity.hex, it) } + } else { + null + } + }.flatten() + +fun List.pubKeys(): List = + mapNotNull { entity -> + when (entity) { + is NProfile -> entity.hex + is NPub -> entity.hex + is NSec -> entity.hex + else -> null + } + } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt index ff87aa3c25..a598bb1d27 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt @@ -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 @@ -31,11 +31,11 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.Entity import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay import com.vitorpamplona.quartz.nip19Bech32.entities.NSec -import com.vitorpamplona.quartz.nip19Bech32.entities.Note import com.vitorpamplona.quartz.utils.Hex import kotlinx.coroutines.CancellationException import java.util.regex.Pattern @@ -43,13 +43,13 @@ import java.util.regex.Pattern object Nip19Parser { private val nip19PlusNip46regex: Pattern = Pattern.compile( - "(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1|nembed1|ncryptsec1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)([\\S]*)", + "(nostr:)?@?((nsec1|npub1|note1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58})|(nevent1|naddr1|nprofile1|nrelay1|nembed1|ncryptsec1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+))([\\S]*)", Pattern.CASE_INSENSITIVE, ) val nip19regex: Pattern = Pattern.compile( - "(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1|nembed1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)([\\S]*)", + "(nostr:)?@?((nsec1|npub1|note1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58})|(nevent1|naddr1|nprofile1|nrelay1|nembed1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+))([\\S]*)", Pattern.CASE_INSENSITIVE, ) @@ -75,8 +75,8 @@ object Nip19Parser { return null } - val type = matcher.group(2) // npub1 - val key = matcher.group(3) // bech32 + val type = matcher.group(3) ?: matcher.group(5) // npub1 + val key = matcher.group(4) ?: matcher.group(6) // bech32 return type!! + key } catch (e: Throwable) { @@ -95,9 +95,9 @@ object Nip19Parser { return null } - val type = matcher.group(2) // npub1 - val key = matcher.group(3) // bech32 - val additionalChars = matcher.group(4) // additional chars + val type = matcher.group(3) ?: matcher.group(5) // npub1 + val key = matcher.group(4) ?: matcher.group(6) // bech32 + val additionalChars = matcher.group(7) // additional chars if (type == null) return null @@ -121,7 +121,7 @@ object Nip19Parser { when (type.lowercase()) { "nsec1" -> NSec.parse(bytes) "npub1" -> NPub.parse(bytes) - "note1" -> Note.parse(bytes) + "note1" -> NNote.parse(bytes) "nprofile1" -> NProfile.parse(bytes) "nevent1" -> NEvent.parse(bytes) "nrelay1" -> NRelay.parse(bytes) @@ -136,11 +136,27 @@ object Nip19Parser { null } - fun parseAll( - content: String, - regex: Pattern, - ): List { - val matcher2 = regex.matcher(content) + fun parseAll(content: String): List { + val matcher = nip19regex.matcher(content) + val returningList = mutableListOf() + while (matcher.find()) { + val type = matcher.group(3) ?: matcher.group(5) // npub1 + val key = matcher.group(4) ?: matcher.group(6) // bech32 + val additionalChars = matcher.group(7) // additional chars + + if (type != null) { + val parsed = parseComponents(type, key, additionalChars)?.entity + + if (parsed != null) { + returningList.add(parsed) + } + } + } + return returningList + } + + fun parseAllEvents(content: String): List { + val matcher2 = nip19regexEvents.matcher(content) val returningList = mutableListOf() while (matcher2.find()) { val type = matcher2.group(2) // npub1 @@ -157,10 +173,6 @@ object Nip19Parser { } return returningList } - - fun parseAll(content: String): List = parseAll(content, nip19regex) - - fun parseAllEvents(content: String): List = parseAll(content, nip19regexEvents) } fun decodePublicKey(key: String): ByteArray = @@ -177,7 +189,7 @@ fun decodePrivateKeyAsHexOrNull(key: String): HexKey? = is NSec -> parsed.hex is NPub -> null is NProfile -> null - is Note -> null + is NNote -> null is NEvent -> null is NEmbed -> null is NRelay -> null @@ -195,7 +207,7 @@ fun decodePublicKeyAsHexOrNull(key: String): HexKey? = is NSec -> Nip01.pubKeyCreate(parsed.hex.hexToByteArray()).toHexKey() is NPub -> parsed.hex is NProfile -> parsed.hex - is Note -> null + is NNote -> null is NEvent -> null is NEmbed -> null is NRelay -> null @@ -213,7 +225,7 @@ fun decodeEventIdAsHexOrNull(key: String): HexKey? = is NSec -> null is NPub -> null is NProfile -> null - is Note -> parsed.hex + is NNote -> parsed.hex is NEvent -> parsed.hex is NAddress -> parsed.aTag() is NEmbed -> null diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/TlvBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/TlvBuilderExt.kt index d929089106..cf9525037d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/TlvBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/TlvBuilderExt.kt @@ -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 @@ -42,6 +42,11 @@ fun TlvBuilder.addStringIfNotNull( data: String?, ) = addStringIfNotNull(type.id, data) +fun TlvBuilder.addStringIfNotBlank( + type: TlvTypes, + data: String, +) = addStringIfNotBlank(type.id, data) + fun TlvBuilder.addHexIfNotNull( type: TlvTypes, data: HexKey?, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/TlvTypes.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/TlvTypes.kt index 1a9f7d430f..9f34c6a333 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/TlvTypes.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/TlvTypes.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/bech32/Bech32Util.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/bech32/Bech32Util.kt index 093121ae5a..066b90dc1c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/bech32/Bech32Util.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/bech32/Bech32Util.kt @@ -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 @@ -168,18 +168,20 @@ object Bech32 { bech32: String, noChecksum: Boolean = false, ): Triple, Encoding> { + val filteredBech32 = bech32.filter { it.code in 33..126 } + var pos = 0 - bech32.forEachIndexed { index, char -> + filteredBech32.forEachIndexed { index, char -> require(char.code in 33..126) { "invalid character $char" } if (char == '1') { pos = index } } - val hrp = bech32.take(pos).lowercase() // strings must be lower case + val hrp = filteredBech32.take(pos).lowercase() // strings must be lower case require(hrp.length in 1..83) { "hrp must contain 1 to 83 characters" } - val data = Array(bech32.length - pos - 1) { map[bech32[pos + 1 + it].code] } + val data = Array(filteredBech32.length - pos - 1) { map[filteredBech32[pos + 1 + it].code] } return if (noChecksum) { Triple(hrp, data, Encoding.Beck32WithoutChecksum) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/Entity.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/Entity.kt index 98744a53df..7ea997ac33 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/Entity.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/Entity.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NAddress.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NAddress.kt index 0e231211be..3fe4138749 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NAddress.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NAddress.kt @@ -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,9 +23,11 @@ package com.vitorpamplona.quartz.nip19Bech32.entities import addHex import addInt import addString -import addStringIfNotNull +import addStringIfNotBlank import android.util.Log import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip19Bech32.TlvTypes import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes @@ -38,10 +40,12 @@ data class NAddress( val kind: Int, val author: String, val dTag: String, - val relay: List, + val relay: List, ) : Entity { fun aTag(): String = Address.assemble(kind, author, dTag) + fun address() = Address(kind, author, dTag) + companion object { fun parse(naddr: String): NAddress? { try { @@ -52,7 +56,6 @@ data class NAddress( } } catch (e: Throwable) { Log.w("NAddress", "Issue trying to Decode NIP19 $this: ${e.message}") - // e.printStackTrace() } return null @@ -68,20 +71,37 @@ data class NAddress( val author = tlv.firstAsHex(TlvTypes.AUTHOR.id) ?: return null val kind = tlv.firstAsInt(TlvTypes.KIND.id) ?: return null - return NAddress(kind, author, d, relay) + return NAddress(kind, author, d, relay.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }) } fun create( kind: Int, pubKeyHex: String, dTag: String, - vararg relays: String?, + relay: NormalizedRelayUrl?, + ): String = + TlvBuilder() + .apply { + addString(TlvTypes.SPECIAL, dTag) + if (relay != null) { + addStringIfNotBlank(TlvTypes.RELAY, relay.url) + } + addHex(TlvTypes.AUTHOR, pubKeyHex) + addInt(TlvTypes.KIND, kind) + }.build() + .toNAddress() + + fun create( + kind: Int, + pubKeyHex: String, + dTag: String, + relays: List, ): String = TlvBuilder() .apply { addString(TlvTypes.SPECIAL, dTag) relays.forEach { - addStringIfNotNull(TlvTypes.RELAY, it) + addStringIfNotBlank(TlvTypes.RELAY, it.url) } addHex(TlvTypes.AUTHOR, pubKeyHex) addInt(TlvTypes.KIND, kind) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NEmbed.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NEmbed.kt index f63bb8ac91..00ebc15943 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NEmbed.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NEmbed.kt @@ -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,9 +23,7 @@ package com.vitorpamplona.quartz.nip19Bech32.entities import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip19Bech32.toNEmbed -import java.io.ByteArrayOutputStream -import java.util.zip.GZIPInputStream -import java.util.zip.GZIPOutputStream +import com.vitorpamplona.quartz.utils.GZip @Immutable data class NEmbed( @@ -34,18 +32,9 @@ data class NEmbed( companion object { fun parse(bytes: ByteArray): NEmbed? { if (bytes.isEmpty()) return null - return NEmbed(Event.fromJson(ungzip(bytes))) + return NEmbed(Event.fromJson(GZip.decompress(bytes))) } - fun create(event: Event): String = gzip(event.toJson()).toNEmbed() - - fun gzip(content: String): ByteArray { - val bos = ByteArrayOutputStream() - GZIPOutputStream(bos).bufferedWriter(Charsets.UTF_8).use { it.write(content) } - val array = bos.toByteArray() - return array - } - - fun ungzip(content: ByteArray): String = GZIPInputStream(content.inputStream()).bufferedReader(Charsets.UTF_8).use { it.readText() } + fun create(event: Event): String = GZip.compress(event.toJson()).toNEmbed() } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NEvent.kt index 7d7d1ae2e1..308220c8f4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NEvent.kt @@ -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,6 +25,8 @@ import addHexIfNotNull import addIntIfNotNull import addStringIfNotNull import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip19Bech32.TlvTypes import com.vitorpamplona.quartz.nip19Bech32.asStringList import com.vitorpamplona.quartz.nip19Bech32.firstAsHex @@ -35,7 +37,7 @@ import com.vitorpamplona.quartz.nip19Bech32.toNEvent @Immutable data class NEvent( val hex: String, - val relay: List, + val relay: List, val author: String?, val kind: Int?, ) : Entity { @@ -52,20 +54,42 @@ data class NEvent( if (hex.isBlank()) return null - return NEvent(hex, relay, author, kind) + return NEvent( + hex, + relay.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }, + author, + kind, + ) } fun create( idHex: String, author: String?, kind: Int?, - vararg relays: String?, + relay: NormalizedRelayUrl?, + ): String = + TlvBuilder() + .apply { + addHex(TlvTypes.SPECIAL, idHex) + if (relay != null) { + addStringIfNotNull(TlvTypes.RELAY, relay.url) + } + addHexIfNotNull(TlvTypes.AUTHOR, author) + addIntIfNotNull(TlvTypes.KIND, kind) + }.build() + .toNEvent() + + fun create( + idHex: String, + author: String?, + kind: Int?, + relays: List, ): String = TlvBuilder() .apply { addHex(TlvTypes.SPECIAL, idHex) relays.forEach { - addStringIfNotNull(TlvTypes.RELAY, it) + addStringIfNotNull(TlvTypes.RELAY, it.url) } addHexIfNotNull(TlvTypes.AUTHOR, author) addIntIfNotNull(TlvTypes.KIND, kind) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/Note.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NNote.kt similarity index 91% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/Note.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NNote.kt index e52bb0ddc6..11bca830ed 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/Note.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NNote.kt @@ -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,13 +27,13 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip19Bech32.toNote @Immutable -data class Note( +data class NNote( val hex: String, ) : Entity { companion object { - fun parse(bytes: ByteArray): Note? { + fun parse(bytes: ByteArray): NNote? { if (bytes.isEmpty()) return null - return Note(bytes.toHexKey()) + return NNote(bytes.toHexKey()) } fun create(eventId: HexKey): String = eventId.hexToByteArray().toNote() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NProfile.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NProfile.kt index c31746b683..55c84758f1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NProfile.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NProfile.kt @@ -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,6 +23,8 @@ package com.vitorpamplona.quartz.nip19Bech32.entities import addHex import addStringIfNotNull import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip19Bech32.TlvTypes import com.vitorpamplona.quartz.nip19Bech32.asStringList import com.vitorpamplona.quartz.nip19Bech32.firstAsHex @@ -33,7 +35,7 @@ import com.vitorpamplona.quartz.nip19Bech32.toNProfile @Immutable data class NProfile( val hex: String, - val relay: List, + val relay: List, ) : Entity { companion object { fun parse(bytes: ByteArray): NProfile? { @@ -46,18 +48,18 @@ data class NProfile( if (hex.isBlank()) return null - return NProfile(hex, relay) + return NProfile(hex, relay.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }) } fun create( authorPubKeyHex: String, - relays: List, + relays: List, ): String = TlvBuilder() .apply { addHex(TlvTypes.SPECIAL, authorPubKeyHex) relays.forEach { - addStringIfNotNull(TlvTypes.RELAY, it) + addStringIfNotNull(TlvTypes.RELAY, it.url) } }.build() .toNProfile() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NPub.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NPub.kt index f9b8fba128..59ec1290aa 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NPub.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NPub.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NRelay.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NRelay.kt index c521ec734f..3e9461fd1b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NRelay.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NRelay.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NSec.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NSec.kt index 09f81b0b00..6f0bf29546 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NSec.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NSec.kt @@ -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,12 +21,16 @@ package com.vitorpamplona.quartz.nip19Bech32.entities import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 @Immutable data class NSec( val hex: String, ) : Entity { + fun toPubKeyHex() = Nip01.pubKeyCreate(hex.hexToByteArray()).toHexKey() + companion object { fun parse(bytes: ByteArray): NSec? { if (bytes.isEmpty()) return null diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/tlv/Tlv.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/tlv/Tlv.kt index 27a4b703c3..046709b51b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/tlv/Tlv.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/tlv/Tlv.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/tlv/TlvBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/tlv/TlvBuilder.kt index 1c6efb71dd..b09881d57b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/tlv/TlvBuilder.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/tlv/TlvBuilder.kt @@ -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 @@ -55,6 +55,15 @@ class TlvBuilder { data: String?, ) = data?.let { addString(type, it) } + fun addStringIfNotBlank( + type: Byte, + data: String, + ) { + if (data.isNotBlank()) { + addString(type, data) + } + } + fun addHexIfNotNull( type: Byte, data: HexKey?, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip21UriScheme/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip21UriScheme/EventExt.kt index 9cb8319f02..28cca61d37 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip21UriScheme/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip21UriScheme/EventExt.kt @@ -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,8 +22,9 @@ package com.vitorpamplona.quartz.nip21UriScheme import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip19Bech32.toNIP19 -fun Event.toNostrUri(relayHint: String? = null): String = "nostr:${toNIP19(relayHint)}" +fun Event.toNostrUri(relayHint: NormalizedRelayUrl? = null): String = "nostr:${toNIP19(relayHint)}" fun EventHintBundle.toNostrUri(): String = "nostr:${toNEvent()}" diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt index 14410a2075..b68eb4a34f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt @@ -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 @@ -29,8 +29,19 @@ import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip19Bech32.addressHints +import com.vitorpamplona.quartz.nip19Bech32.addressIds +import com.vitorpamplona.quartz.nip19Bech32.eventHints +import com.vitorpamplona.quartz.nip19Bech32.eventIds +import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints +import com.vitorpamplona.quartz.nip19Bech32.pubKeys import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri import com.vitorpamplona.quartz.nip22Comments.tags.ReplyAddressTag import com.vitorpamplona.quartz.nip22Comments.tags.ReplyAuthorTag @@ -43,7 +54,9 @@ import com.vitorpamplona.quartz.nip22Comments.tags.RootEventTag import com.vitorpamplona.quartz.nip22Comments.tags.RootIdentifierTag import com.vitorpamplona.quartz.nip22Comments.tags.RootKindTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.lastNotNullOfOrNull @@ -59,12 +72,59 @@ class CommentEvent( RootScope, EventHintProvider, PubKeyHintProvider, - AddressHintProvider { - override fun pubKeyHints() = tags.mapNotNull(RootAuthorTag::parseAsHint) + tags.mapNotNull(ReplyAuthorTag::parseAsHint) + AddressHintProvider, + SearchableEvent { + override fun indexableContent() = content - override fun eventHints() = tags.mapNotNull(RootEventTag::parseAsHint) + tags.mapNotNull(ReplyEventTag::parseAsHint) + override fun pubKeyHints(): List { + val pHints = + tags.mapNotNull(RootAuthorTag::parseAsHint) + + tags.mapNotNull(ReplyAuthorTag::parseAsHint) + val nip19Hints = citedNIP19().pubKeyHints() - override fun addressHints() = tags.mapNotNull(RootAddressTag::parseAsHint) + tags.mapNotNull(ReplyAddressTag::parseAsHint) + return pHints + nip19Hints + } + + override fun linkedPubKeys(): List { + val pHints = + tags.mapNotNull(RootAuthorTag::parseKey) + + tags.mapNotNull(ReplyAuthorTag::parseKey) + val nip19Hints = citedNIP19().pubKeys() + + return pHints + nip19Hints + } + + override fun eventHints(): List { + val eHints = tags.mapNotNull(RootEventTag::parseAsHint) + tags.mapNotNull(ReplyEventTag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseEventAsHint) + val nip19Hints = citedNIP19().eventHints() + + return eHints + qHints + nip19Hints + } + + override fun linkedEventIds(): List { + val eHints = tags.mapNotNull(RootEventTag::parseKey) + tags.mapNotNull(ReplyEventTag::parseKey) + val qHints = tags.mapNotNull(QTag::parseEventId) + val nip19Hints = citedNIP19().eventIds() + + return eHints + qHints + nip19Hints + } + + override fun addressHints(): List { + val aHints = tags.mapNotNull(RootAddressTag::parseAsHint) + tags.mapNotNull(ReplyAddressTag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseAddressAsHint) + val nip19Hints = citedNIP19().addressHints() + + return aHints + qHints + nip19Hints + } + + override fun linkedAddressIds(): List { + val aHints = tags.mapNotNull(RootAddressTag::parseAddressId) + tags.mapNotNull(ReplyAddressTag::parseAddressId) + val qHints = tags.mapNotNull(QTag::parseAddressId) + val nip19Hints = citedNIP19().addressIds() + + return aHints + qHints + nip19Hints + } fun rootAuthor() = tags.firstNotNullOfOrNull(RootAuthorTag::parse) @@ -74,6 +134,14 @@ class CommentEvent( fun replyAuthors() = tags.filter(ReplyAuthorTag::match) + fun rootAuthorKeys() = tags.mapNotNull(RootAuthorTag::parseKey) + + fun replyAuthorKeys() = tags.mapNotNull(ReplyAuthorTag::parseKey) + + fun rootAuthorHints() = tags.mapNotNull(RootAuthorTag::parseAsHint) + + fun replyAuthorHints() = tags.mapNotNull(ReplyAuthorTag::parseAsHint) + fun rootScopes() = tags.filter { RootIdentifierTag.match(it) || RootAddressTag.match(it) || RootEventTag.match(it) } fun rootKinds() = tags.filter(RootKindTag::match) @@ -82,19 +150,29 @@ class CommentEvent( fun directKinds() = tags.filter(ReplyKindTag::match) - fun isGeohashTag(tag: Array) = tag.size > 1 && (tag[0] == "i" || tag[0] == "I") && tag[1].startsWith("geo:") + /** root and reply scope search */ + fun isTaggedScope(scopeId: String) = tags.any { RootIdentifierTag.isTagged(it, scopeId) || ReplyIdentifierTag.isTagged(it, scopeId) } - private fun getGeoHashList() = tags.filter { isGeohashTag(it) } + fun isTaggedScopes(scopeIds: Set) = tags.any { RootIdentifierTag.isTagged(it, scopeIds) || ReplyIdentifierTag.isTagged(it, scopeIds) } - fun hasGeohashes() = tags.any { isGeohashTag(it) } + fun isTaggedScope( + value: String, + match: (String, String) -> Boolean, + ) = tags.any { RootIdentifierTag.isTagged(it, value, match) || ReplyIdentifierTag.isTagged(it, value, match) } - fun geohashes() = getGeoHashList().map { it[1].drop(4).lowercase() } + fun firstTaggedScopeIn(scopeIds: Set) = tags.firstNotNullOfOrNull { RootIdentifierTag.matchOrNull(it, scopeIds) ?: ReplyIdentifierTag.matchOrNull(it, scopeIds) } - fun getGeoHash(): String? = geohashes().maxByOrNull { it.length } + fun isScoped(scopeTest: (String) -> Boolean) = tags.any { RootIdentifierTag.isTagged(it, scopeTest) || ReplyIdentifierTag.isTagged(it, scopeTest) } - fun isTaggedGeoHash(hashtag: String) = tags.any { isGeohashTag(it) && it[1].endsWith(hashtag, true) } + fun hasRootScopeKind(kind: String) = tags.any { RootKindTag.isKind(it, kind) } - fun isTaggedGeoHashes(hashtags: Set) = geohashes().any { it in hashtags } + fun hasReplyScopeKind(kind: String) = tags.any { ReplyKindTag.isKind(it, kind) } + + fun hasScopeKind(kind: String) = tags.any { RootKindTag.isKind(it, kind) || ReplyKindTag.isKind(it, kind) } + + fun scopeValues(parser: (String) -> String?) = tags.mapNotNull { RootIdentifierTag.parse(it)?.let { parser(it) } } + + fun firstScopeValue(parser: (String) -> String?) = tags.firstNotNullOfOrNull { RootIdentifierTag.parse(it)?.let { parser(it) } } override fun markedReplyTos(): List = tags.mapNotNull(ReplyEventTag::parseKey) + @@ -154,10 +232,18 @@ class CommentEvent( ) = eventTemplate(KIND, msg, createdAt) { alt(ALT + extId.toScope()) - rootExternalIdentity(extId) + if (extId is GeohashId) { + GeoHashTag.geoMipMap(extId.geohash).forEach { rootExternalIdentity(GeohashId(it, extId.hint)) } + } else { + rootExternalIdentity(extId) + } rootKind(extId) - replyExternalIdentity(extId) + if (extId is GeohashId) { + GeoHashTag.geoMipMap(extId.geohash).forEach { replyExternalIdentity(GeohashId(it, extId.hint)) } + } else { + replyExternalIdentity(extId) + } replyKind(extId) initializer() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/RootScope.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/RootScope.kt index de9c13c9e1..05de5032d4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/RootScope.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/RootScope.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/TagArrayBuilderExt.kt index 3592427787..d871c54890 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/TagArrayBuilderExt.kt @@ -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,6 +22,7 @@ package com.vitorpamplona.quartz.nip22Comments import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.pTag import com.vitorpamplona.quartz.nip01Core.tags.people.pTags @@ -39,16 +40,18 @@ import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId fun TagArrayBuilder.rootAddress( addressId: String, - relayHint: String?, + relayHint: NormalizedRelayUrl?, ) = addUnique(RootAddressTag.assemble(addressId, relayHint)) fun TagArrayBuilder.rootEvent( eventId: String, - relayHint: String?, + relayHint: NormalizedRelayUrl?, pubkey: String?, ) = addUnique(RootEventTag.assemble(eventId, relayHint, pubkey)) -fun TagArrayBuilder.rootExternalIdentity(id: ExternalId) = addAll(RootIdentifierTag.assemble(id)) +fun TagArrayBuilder.rootExternalIdentity(id: ExternalId) = add(RootIdentifierTag.assemble(id)) + +fun TagArrayBuilder.rootExternalIdentities(ids: List) = addAll(ids.map { RootIdentifierTag.assemble(it) }) fun TagArrayBuilder.rootKind(kind: String) = addUnique(RootKindTag.assemble(kind)) @@ -58,31 +61,33 @@ fun TagArrayBuilder.rootKind(id: ExternalId) = addUnique(RootKindT fun TagArrayBuilder.rootAuthor( pubKey: HexKey, - relay: String?, + relay: NormalizedRelayUrl?, ) = add(RootAuthorTag.assemble(pubKey, relay)) fun TagArrayBuilder.replyAddress( addressId: String, - relayHint: String?, + relayHint: NormalizedRelayUrl?, ) = addUnique(ReplyAddressTag.assemble(addressId, relayHint)) fun TagArrayBuilder.replyEvent( eventId: String, - relayHint: String?, + relayHint: NormalizedRelayUrl?, pubkey: String?, ) = addUnique(ReplyEventTag.assemble(eventId, relayHint, pubkey)) -fun TagArrayBuilder.replyExternalIdentity(id: ExternalId) = addAll(ReplyIdentifierTag.assemble(id)) +fun TagArrayBuilder.replyExternalIdentity(id: ExternalId) = add(ReplyIdentifierTag.assemble(id)) + +fun TagArrayBuilder.replyExternalIdentities(ids: List) = addAll(ids.map { ReplyIdentifierTag.assemble(it) }) fun TagArrayBuilder.replyKind(kind: String) = addUnique(ReplyKindTag.assemble(kind)) fun TagArrayBuilder.replyKind(kind: Int) = addUnique(ReplyKindTag.assemble(kind)) -fun TagArrayBuilder.replyKind(id: ExternalId) = addUnique(RootKindTag.assemble(id)) +fun TagArrayBuilder.replyKind(id: ExternalId) = addUnique(ReplyKindTag.assemble(id)) fun TagArrayBuilder.replyAuthor( pubKey: HexKey, - relay: String?, + relay: NormalizedRelayUrl?, ) = add(ReplyAuthorTag.assemble(pubKey, relay)) fun TagArrayBuilder.notify(list: List) = pTags(list) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyAddressTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyAddressTag.kt index 43933ee440..4582ce1733 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyAddressTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyAddressTag.kt @@ -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,6 +25,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -32,7 +34,7 @@ import com.vitorpamplona.quartz.utils.ensure @Immutable class ReplyAddressTag( val addressId: String, - val relay: String? = null, + val relay: NormalizedRelayUrl? = null, ) { fun toTagArray() = assemble(addressId, relay) @@ -47,7 +49,10 @@ class ReplyAddressTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return ReplyAddressTag(tag[1], tag.getOrNull(2)) + + val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return ReplyAddressTag(tag[1], relayHint) } @JvmStatic @@ -64,21 +69,25 @@ class ReplyAddressTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } ensure(tag[2].isNotEmpty()) { return null } - return AddressHint(tag[1], tag[2]) + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(relayHint != null) { return null } + + return AddressHint(tag[1], relayHint) } @JvmStatic fun assemble( addressId: HexKey, - relay: String?, - ) = arrayOfNotNull(TAG_NAME, addressId, relay) + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, addressId, relay?.url) @JvmStatic fun assemble( kind: Int, pubKey: String, dTag: String, - relay: String?, + relay: NormalizedRelayUrl?, ) = assemble(Address.assemble(kind, pubKey, dTag), relay) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyAuthorTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyAuthorTag.kt index c6e8598d7c..f006a731e1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyAuthorTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyAuthorTag.kt @@ -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,6 +25,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -32,7 +34,7 @@ import com.vitorpamplona.quartz.utils.ensure @Immutable data class ReplyAuthorTag( override val pubKey: HexKey, - override val relayHint: String? = null, + override val relayHint: NormalizedRelayUrl? = null, ) : PubKeyReferenceTag { fun toTagArray() = assemble(pubKey, relayHint) @@ -47,7 +49,10 @@ data class ReplyAuthorTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return ReplyAuthorTag(tag[1], tag.getOrNull(2)) + + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return ReplyAuthorTag(tag[1], hint) } @JvmStatic @@ -64,13 +69,17 @@ data class ReplyAuthorTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } ensure(tag[2].isNotEmpty()) { return null } - return PubKeyHint(tag[1], tag[2]) + + val hint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(hint != null) { return null } + + return PubKeyHint(tag[1], hint) } @JvmStatic fun assemble( pubkey: HexKey, - relayHint: String?, - ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint) + relayHint: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint?.url) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyEventTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyEventTag.kt index 337e6f35b5..4f891256f2 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyEventTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyEventTag.kt @@ -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,6 +25,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.events.EventReference import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.arrayOfNotNull @@ -34,7 +36,7 @@ import com.vitorpamplona.quartz.utils.ensure class ReplyEventTag( val ref: EventReference, ) { - constructor(eventId: String, relayHint: String?, pubkey: String?) : this(EventReference(eventId, relayHint, pubkey)) + constructor(eventId: String, relayHint: NormalizedRelayUrl?, pubkey: String?) : this(EventReference(eventId, pubkey, relayHint)) fun toTagArray() = assemble(ref) @@ -61,7 +63,7 @@ class ReplyEventTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return ReplyEventTag(tag[1], tag.getOrNull(2), tag.getOrNull(3)) + return ReplyEventTag(tag[1], tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) }, tag.getOrNull(3)) } @JvmStatic @@ -87,15 +89,19 @@ class ReplyEventTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } ensure(tag[2].isNotEmpty()) { return null } - return EventIdHint(tag[1], tag[2]) + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(relayHint != null) { return null } + + return EventIdHint(tag[1], relayHint) } @JvmStatic fun assemble( eventId: HexKey, - relay: String?, + relay: NormalizedRelayUrl?, pubkey: String?, - ) = arrayOfNotNull(TAG_NAME, eventId, relay, pubkey) + ) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, pubkey) @JvmStatic fun assemble(ref: EventReference) = assemble(ref.eventId, ref.relayHint, ref.author) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyIdentifierTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyIdentifierTag.kt index f0e2299e89..a3c9a23edf 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyIdentifierTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyIdentifierTag.kt @@ -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,9 +23,17 @@ package com.vitorpamplona.quartz.nip22Comments.tags import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has -import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash +import com.vitorpamplona.quartz.nip46RemoteSigner.getOrNull import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId -import com.vitorpamplona.quartz.nip73ExternalIds.GeohashId +import com.vitorpamplona.quartz.nip73ExternalIds.books.BookId +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId +import com.vitorpamplona.quartz.nip73ExternalIds.movies.MovieId +import com.vitorpamplona.quartz.nip73ExternalIds.papers.PaperId +import com.vitorpamplona.quartz.nip73ExternalIds.podcasts.PodcastEpisodeId +import com.vitorpamplona.quartz.nip73ExternalIds.podcasts.PodcastFeedId +import com.vitorpamplona.quartz.nip73ExternalIds.podcasts.PodcastPublisherId +import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId +import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -37,6 +45,43 @@ class ReplyIdentifierTag { @JvmStatic fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + @JvmStatic + fun isTagged( + tag: Array, + encodedScope: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == encodedScope + + fun isTagged( + tag: Array, + encodedScope: Set, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in encodedScope + + fun matchOrNull( + tag: Array, + encodedScope: Set, + ) = if (tag.has(1) && tag[0] == TAG_NAME && tag[1] in encodedScope) { + tag[1] + } else { + null + } + + fun isTagged( + tag: Array, + test: (String) -> Boolean, + ) = tag.has(1) && tag[0] == TAG_NAME && test(tag[1]) + + fun isTagged( + tag: Array, + value: String, + match: (String, String) -> Boolean, + ) = tag.has(1) && tag[0] == TAG_NAME && match(tag[1], value) + + fun isTagged( + tag: Array, + value: Set, + match: (String, Set) -> Boolean, + ) = tag.has(1) && tag[0] == TAG_NAME && match(tag[1], value) + @JvmStatic fun parse(tag: Tag): String? { ensure(tag.has(1)) { return null } @@ -45,6 +90,26 @@ class ReplyIdentifierTag { return tag[1] } + @JvmStatic + fun parseExternalId(tag: Tag): ExternalId? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + val value = tag[1] + val hint = tag.getOrNull(2) + + return BookId.parse(value, hint) + ?: HashtagId.parse(value, hint) + ?: GeohashId.parse(value, hint) + ?: MovieId.parse(value, hint) + ?: PaperId.parse(value, hint) + ?: PodcastEpisodeId.parse(value, hint) + ?: PodcastFeedId.parse(value, hint) + ?: PodcastPublisherId.parse(value, hint) + ?: UrlId.parse(value, hint) + } + @JvmStatic fun assemble( identity: String, @@ -52,10 +117,6 @@ class ReplyIdentifierTag { ) = arrayOfNotNull(TAG_NAME, identity, hint) @JvmStatic - fun assemble(id: ExternalId): List> = - when (id) { - is GeohashId -> GeoHash.geoMipMap(id.geohash).map { assemble(it, id.hint) } - else -> listOf(assemble(id.toScope(), id.hint())) - } + fun assemble(id: ExternalId): Array = assemble(id.toScope(), id.hint()) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyKindTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyKindTag.kt index 43647bcb29..7a19f9aa86 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyKindTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyKindTag.kt @@ -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 @@ -32,6 +32,12 @@ class ReplyKindTag { @JvmStatic fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + @JvmStatic + fun isKind( + tag: Tag, + kind: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == kind + @JvmStatic fun isTagged( tag: Tag, @@ -59,7 +65,7 @@ class ReplyKindTag { fun assemble(kind: Int) = arrayOf(TAG_NAME, kind.toString()) @JvmStatic - fun assemble(id: ExternalId) = RootKindTag.assemble(id.toKind()) + fun assemble(id: ExternalId) = assemble(id.toKind()) @JvmStatic fun assemble(kinds: List): List = kinds.map { assemble(it) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootAddressTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootAddressTag.kt index 46263abe24..9278c3e0cd 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootAddressTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootAddressTag.kt @@ -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,6 +25,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -32,7 +34,7 @@ import com.vitorpamplona.quartz.utils.ensure @Immutable class RootAddressTag( val addressId: String, - val relay: String? = null, + val relay: NormalizedRelayUrl? = null, ) { fun toTagArray() = assemble(addressId, relay) @@ -59,7 +61,10 @@ class RootAddressTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } - return RootAddressTag(tag[1], tag.getOrNull(2)) + + val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return RootAddressTag(tag[1], relayHint) } @JvmStatic @@ -84,21 +89,25 @@ class RootAddressTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } ensure(tag[2].isNotEmpty()) { return null } - return AddressHint(tag[1], tag[2]) + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(relayHint != null) { return null } + + return AddressHint(tag[1], relayHint) } @JvmStatic fun assemble( addressId: HexKey, - relay: String?, - ) = arrayOfNotNull(TAG_NAME, addressId, relay) + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, addressId, relay?.url) @JvmStatic fun assemble( kind: Int, pubKey: String, dTag: String, - relay: String?, + relay: NormalizedRelayUrl?, ) = assemble(Address.assemble(kind, pubKey, dTag), relay) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootAuthorTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootAuthorTag.kt index e548f60bc3..a4afdeda8b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootAuthorTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootAuthorTag.kt @@ -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,6 +26,8 @@ import com.vitorpamplona.quartz.nip01Core.core.PUBKEY_LENGTH import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -33,7 +35,7 @@ import com.vitorpamplona.quartz.utils.ensure @Immutable data class RootAuthorTag( override val pubKey: HexKey, - override val relayHint: String? = null, + override val relayHint: NormalizedRelayUrl? = null, ) : PubKeyReferenceTag { fun toTagArray() = assemble(pubKey, relayHint) @@ -46,7 +48,10 @@ data class RootAuthorTag( @JvmStatic fun parse(tag: Tag): ReplyAuthorTag? { if (tag.size < 2 || tag[0] != TAG_NAME || tag[1].length != PUBKEY_LENGTH) return null - return ReplyAuthorTag(tag[1], tag.getOrNull(2)) + + val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return ReplyAuthorTag(tag[1], relayHint) } @JvmStatic @@ -54,7 +59,10 @@ data class RootAuthorTag( ensure(tag.size >= 2) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == PUBKEY_LENGTH) { return null } - return ReplyAuthorTag(tag[1], tag.getOrNull(2)) + + val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return ReplyAuthorTag(tag[1], relayHint) } @JvmStatic @@ -69,13 +77,17 @@ data class RootAuthorTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } ensure(tag[2].isNotEmpty()) { return null } - return PubKeyHint(tag[1], tag[2]) + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(relayHint != null) { return null } + + return PubKeyHint(tag[1], relayHint) } @JvmStatic fun assemble( pubkey: HexKey, - relayHint: String?, - ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint) + relayHint: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint?.url) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootEventTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootEventTag.kt index 9796e5ec6c..ce210e6e8a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootEventTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootEventTag.kt @@ -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,6 +25,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.events.EventReference import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.arrayOfNotNull @@ -34,7 +36,7 @@ import com.vitorpamplona.quartz.utils.ensure class RootEventTag( val ref: EventReference, ) { - constructor(eventId: String, relayHint: String?, pubkey: String?) : this(EventReference(eventId, relayHint, pubkey)) + constructor(eventId: String, relayHint: NormalizedRelayUrl?, pubkey: String?) : this(EventReference(eventId, pubkey, relayHint)) fun toTagArray() = assemble(ref) @@ -61,7 +63,10 @@ class RootEventTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return RootEventTag(tag[1], tag.getOrNull(2), tag.getOrNull(3)) + + val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return RootEventTag(tag[1], relayHint, tag.getOrNull(3)) } @JvmStatic @@ -87,15 +92,19 @@ class RootEventTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } ensure(tag[2].isNotEmpty()) { return null } - return EventIdHint(tag[1], tag[2]) + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(relayHint != null) { return null } + + return EventIdHint(tag[1], relayHint) } @JvmStatic fun assemble( eventId: HexKey, - relay: String?, + relay: NormalizedRelayUrl?, pubkey: String?, - ) = arrayOfNotNull(TAG_NAME, eventId, relay, pubkey) + ) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, pubkey) @JvmStatic fun assemble(ref: EventReference) = assemble(ref.eventId, ref.relayHint, ref.author) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootIdentifierTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootIdentifierTag.kt index d896b3e665..db5d43c61a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootIdentifierTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootIdentifierTag.kt @@ -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,20 +23,64 @@ package com.vitorpamplona.quartz.nip22Comments.tags import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has -import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash +import com.vitorpamplona.quartz.nip46RemoteSigner.getOrNull import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId -import com.vitorpamplona.quartz.nip73ExternalIds.GeohashId +import com.vitorpamplona.quartz.nip73ExternalIds.books.BookId +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId +import com.vitorpamplona.quartz.nip73ExternalIds.movies.MovieId +import com.vitorpamplona.quartz.nip73ExternalIds.papers.PaperId +import com.vitorpamplona.quartz.nip73ExternalIds.podcasts.PodcastEpisodeId +import com.vitorpamplona.quartz.nip73ExternalIds.podcasts.PodcastFeedId +import com.vitorpamplona.quartz.nip73ExternalIds.podcasts.PodcastPublisherId +import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId +import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @Immutable -class RootIdentifierTag { +class RootIdentifierTag { companion object { const val TAG_NAME = "I" @JvmStatic fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + fun isTagged( + tag: Array, + encodedScope: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == encodedScope + + fun isTagged( + tag: Array, + encodedScope: Set, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in encodedScope + + fun matchOrNull( + tag: Array, + encodedScope: Set, + ) = if (tag.has(1) && tag[0] == TAG_NAME && tag[1] in encodedScope) { + tag[1] + } else { + null + } + + fun isTagged( + tag: Array, + test: (String) -> Boolean, + ) = tag.has(1) && tag[0] == TAG_NAME && test(tag[1]) + + fun isTagged( + tag: Array, + value: String, + match: (String, String) -> Boolean, + ) = tag.has(1) && tag[0] == TAG_NAME && match(tag[1], value) + + fun isTagged( + tag: Array, + value: Set, + match: (String, Set) -> Boolean, + ) = tag.has(1) && tag[0] == TAG_NAME && match(tag[1], value) + @JvmStatic fun parse(tag: Tag): String? { ensure(tag.has(1)) { return null } @@ -45,6 +89,26 @@ class RootIdentifierTag { return tag[1] } + @JvmStatic + fun parseExternalId(tag: Tag): ExternalId? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + val value = tag[1] + val hint = tag.getOrNull(2) + + return BookId.parse(value, hint) + ?: HashtagId.parse(value, hint) + ?: GeohashId.parse(value, hint) + ?: MovieId.parse(value, hint) + ?: PaperId.parse(value, hint) + ?: PodcastEpisodeId.parse(value, hint) + ?: PodcastFeedId.parse(value, hint) + ?: PodcastPublisherId.parse(value, hint) + ?: UrlId.parse(value, hint) + } + @JvmStatic fun assemble( identity: String, @@ -52,10 +116,6 @@ class RootIdentifierTag { ) = arrayOfNotNull(TAG_NAME, identity, hint) @JvmStatic - fun assemble(id: ExternalId): List> = - when (id) { - is GeohashId -> GeoHash.geoMipMap(id.geohash).map { assemble(it, id.hint) } - else -> listOf(assemble(id.toScope(), id.hint())) - } + fun assemble(id: ExternalId): Array = assemble(id.toScope(), id.hint()) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootKindTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootKindTag.kt index 7a1a31767b..a295a4da71 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootKindTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootKindTag.kt @@ -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 @@ -32,6 +32,12 @@ class RootKindTag { @JvmStatic fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + @JvmStatic + fun isKind( + tag: Tag, + kind: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == kind + @JvmStatic fun parse(tag: Tag): String? { ensure(tag.has(1)) { return null } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt index 5136ec480b..23b131b0bc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt @@ -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,6 +27,10 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address @@ -35,12 +39,19 @@ import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip19Bech32.addressHints +import com.vitorpamplona.quartz.nip19Bech32.addressIds +import com.vitorpamplona.quartz.nip19Bech32.eventHints +import com.vitorpamplona.quartz.nip19Bech32.eventIds +import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints +import com.vitorpamplona.quartz.nip19Bech32.pubKeys import com.vitorpamplona.quartz.nip22Comments.RootScope import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils import java.util.UUID @@ -57,16 +68,55 @@ class LongTextNoteEvent( EventHintProvider, PubKeyHintProvider, AddressHintProvider, - RootScope { - override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + RootScope, + SearchableEvent { + override fun indexableContent() = "title: " + title() + "\nsummary: " + summary() + "\n" + content - override fun eventHints() = tags.mapNotNull(QTag::parseEventAsHint) + override fun eventHints(): List { + val qHints = tags.mapNotNull(QTag::parseEventAsHint) + val nip19Hints = citedNIP19().eventHints() - override fun addressHints() = tags.mapNotNull(QTag::parseAddressAsHint) + return qHints + nip19Hints + } + + override fun linkedEventIds(): List { + val qHints = tags.mapNotNull(QTag::parseEventId) + val nip19Hints = citedNIP19().eventIds() + + return qHints + nip19Hints + } + + override fun addressHints(): List { + val qHints = tags.mapNotNull(QTag::parseAddressAsHint) + val nip19Hints = citedNIP19().addressHints() + + return qHints + nip19Hints + } + + override fun linkedAddressIds(): List { + val qHints = tags.mapNotNull(QTag::parseAddressId) + val nip19Hints = citedNIP19().addressIds() + + return qHints + nip19Hints + } + + override fun pubKeyHints(): List { + val pHints = tags.mapNotNull(PTag::parseAsHint) + val nip19Hints = citedNIP19().pubKeyHints() + + return pHints + nip19Hints + } + + override fun linkedPubKeys(): List { + val pHints = tags.mapNotNull(PTag::parseKey) + val nip19Hints = citedNIP19().pubKeys() + + return pHints + nip19Hints + } override fun dTag() = tags.dTag() - override fun aTag(relayHint: String?) = ATag(kind, pubKey, dTag(), relayHint) + override fun aTag(relayHint: NormalizedRelayUrl?) = ATag(kind, pubKey, dTag(), relayHint) override fun address() = Address(kind, pubKey, dTag()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/TagArrayBuilderExt.kt index bcbbf782c1..1ca350d2e8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/tags/ImageTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/tags/ImageTag.kt index 0ab6c6714f..efe83c17a5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/tags/ImageTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/tags/ImageTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/tags/PublishedAtTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/tags/PublishedAtTag.kt index 21f33adb44..466b821404 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/tags/PublishedAtTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/tags/PublishedAtTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/tags/SummaryTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/tags/SummaryTag.kt index 6acbac79d3..4ea010ab34 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/tags/SummaryTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/tags/SummaryTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/tags/TitleTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/tags/TitleTag.kt index 77b6ab6d66..b49e0e9a52 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/tags/TitleTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/tags/TitleTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip25Reactions/ReactionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip25Reactions/ReactionEvent.kt index 6abac57172..3c11b0d017 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip25Reactions/ReactionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip25Reactions/ReactionEvent.kt @@ -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 @@ -54,10 +54,16 @@ class ReactionEvent( AddressHintProvider { override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + fun originalPost() = tags.mapNotNull(ETag::parseId) fun originalAuthor() = tags.mapNotNull(PTag::parseKey) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/ChannelListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/ChannelListEvent.kt deleted file mode 100644 index e892b6f8d0..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/ChannelListEvent.kt +++ /dev/null @@ -1,271 +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.quartz.nip28PublicChat - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip31Alts.AltTag -import com.vitorpamplona.quartz.nip51Lists.GeneralListEvent -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.utils.bytesUsedInMemory -import com.vitorpamplona.quartz.utils.pointerSizeInBytes -import kotlinx.collections.immutable.ImmutableSet - -@Immutable -class ChannelListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - @Transient var publicAndPrivateEventCache: ImmutableSet? = null - - override fun countMemory(): Long = - super.countMemory() + - 32 + (publicAndPrivateEventCache?.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() } ?: 0L) // rough calculation - - fun publicAndPrivateEvents( - signer: NostrSigner, - onReady: (ImmutableSet) -> Unit, - ) { - publicAndPrivateEventCache?.let { eventList -> - onReady(eventList) - return - } - - privateTagsOrEmpty(signer) { - publicAndPrivateEventCache = filterTagList("e", it) - - publicAndPrivateEventCache?.let { eventList -> - onReady(eventList) - } - } - } - - companion object { - const val KIND = 10005 - const val ALT = "Public Chat List" - - fun blockListFor(pubKeyHex: HexKey): String = "$KIND:$pubKeyHex:" - - fun createListWithTag( - key: String, - tag: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChannelListEvent) -> Unit, - ) { - if (isPrivate) { - encryptTags(arrayOf(arrayOf(key, tag)), signer) { encryptedTags -> - create( - content = encryptedTags, - tags = emptyArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } else { - create( - content = "", - tags = arrayOf(arrayOf(key, tag)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - - fun createListWithEvent( - eventId: HexKey, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChannelListEvent) -> Unit, - ) = createListWithTag("e", eventId, isPrivate, signer, createdAt, onReady) - - fun addEvents( - earlierVersion: ChannelListEvent, - listEvents: List, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChannelListEvent) -> Unit, - ) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = - privateTags.plus( - listEvents.map { arrayOf("e", it) }, - ), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = earlierVersion.tags, - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } else { - create( - content = earlierVersion.content, - tags = - earlierVersion.tags.plus( - listEvents.map { arrayOf("e", it) }, - ), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - - fun addEvent( - earlierVersion: ChannelListEvent, - event: HexKey, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChannelListEvent) -> Unit, - ) = addTag(earlierVersion, "e", event, isPrivate, signer, createdAt, onReady) - - fun addTag( - earlierVersion: ChannelListEvent, - key: String, - tag: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChannelListEvent) -> Unit, - ) { - earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> - if (!isTagged) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = privateTags.plus(element = arrayOf(key, tag)), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = earlierVersion.tags, - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } else { - create( - content = earlierVersion.content, - tags = earlierVersion.tags.plus(element = arrayOf(key, tag)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } - } - - fun removeEvent( - earlierVersion: ChannelListEvent, - event: HexKey, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChannelListEvent) -> Unit, - ) = removeTag(earlierVersion, "e", event, isPrivate, signer, createdAt, onReady) - - fun removeTag( - earlierVersion: ChannelListEvent, - key: String, - tag: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChannelListEvent) -> Unit, - ) { - earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> - if (isTagged) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = - privateTags - .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } - .toTypedArray(), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = - earlierVersion.tags - .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } - .toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } else { - create( - content = earlierVersion.content, - tags = - earlierVersion.tags - .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } - .toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } - } - - fun create( - content: String, - tags: Array>, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChannelListEvent) -> Unit, - ) { - val newTags = - if (tags.any { it.size > 1 && it[0] == "alt" }) { - tags - } else { - tags + AltTag.assemble(ALT) - } - - signer.sign(createdAt, KIND, newTags, content, onReady) - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt index fc7f93afd6..c9dac836ce 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt @@ -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,14 +20,19 @@ */ package com.vitorpamplona.quartz.nip28PublicChat.admin +import android.util.Log import androidx.compose.runtime.Immutable +import com.fasterxml.jackson.core.JsonParseException import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelData +import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelDataNorm import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils @@ -41,9 +46,33 @@ class ChannelCreateEvent( sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), EventHintProvider { + @Transient + var cache: ChannelDataNorm? = null + override fun eventHints() = channelInfo().relays?.map { EventIdHint(id, it) } ?: emptyList() - fun channelInfo() = ChannelData.parse(content) ?: ChannelData() + override fun linkedEventIds() = listOf(id) + + fun isEncrypted() = tags.any { it.has(1) && it[0] == "encrypted" && it[1] == "true" } + + fun channelInfo(): ChannelDataNorm { + cache?.let { return it } + + val newInfo = + try { + if (isEncrypted()) { + ChannelDataNorm() + } else { + ChannelData.parse(content)?.normalize() ?: ChannelDataNorm() + } + } catch (e: JsonParseException) { + Log.w("ChannelCreateEvent", "Failure to parse ${this.toJson()}", e) + ChannelDataNorm() + } + + cache = newInfo + return newInfo + } companion object { const val KIND = 40 @@ -52,13 +81,13 @@ class ChannelCreateEvent( name: String?, about: String?, picture: String?, - relays: List?, + relays: List?, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, - ) = build(ChannelData(name, about, picture, relays), createdAt, initializer) + ) = build(ChannelDataNorm(name, about, picture, relays), createdAt, initializer) fun build( - data: ChannelData, + data: ChannelDataNorm, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, data.toContent(), createdAt) { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelHideMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelHideMessageEvent.kt index 1c4c3e0d8e..66b579577d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelHideMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelHideMessageEvent.kt @@ -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 @@ -46,6 +46,8 @@ class ChannelHideMessageEvent( EventHintProvider { override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + fun eventsToHide() = tags.taggedEventIds() companion object { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt index bc98fc090a..526214f20b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt @@ -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,21 @@ */ package com.vitorpamplona.quartz.nip28PublicChat.admin +import android.util.Log import androidx.compose.runtime.Immutable +import com.fasterxml.jackson.core.JsonParseException import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip28PublicChat.base.BasePublicChatEvent import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelData +import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelDataNorm import com.vitorpamplona.quartz.nip28PublicChat.base.channel import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils @@ -44,9 +49,36 @@ class ChannelMetadataEvent( sig: HexKey, ) : BasePublicChatEvent(id, pubKey, createdAt, KIND, tags, content, sig), EventHintProvider { - override fun eventHints() = channelInfo().relays?.map { EventIdHint(id, it) } ?: emptyList() + @Transient + var cache: ChannelDataNorm? = null - fun channelInfo() = ChannelData.parse(content) ?: ChannelData() + override fun eventHints() = + channelInfo().relays?.mapNotNull { relay -> + channelId()?.let { EventIdHint(it, relay) } + } ?: emptyList() + + override fun linkedEventIds() = channelId()?.let { listOf(it) } ?: emptyList() + + fun isEncrypted() = tags.any { it.has(1) && it[0] == "encrypted" && it[1] == "true" } + + fun channelInfo(): ChannelDataNorm { + cache?.let { return it } + + val newInfo = + try { + if (isEncrypted()) { + ChannelDataNorm() + } else { + ChannelData.parse(content)?.normalize() ?: ChannelDataNorm() + } + } catch (e: JsonParseException) { + Log.w("ChannelCreateEvent", "Failure to parse ${this.toJson()}", e) + ChannelDataNorm() + } + + cache = newInfo + return newInfo + } companion object { const val KIND = 41 @@ -56,24 +88,24 @@ class ChannelMetadataEvent( name: String?, about: String?, picture: String?, - relays: List?, + relays: List?, channel: EventHintBundle, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, - ) = build(ChannelData(name, about, picture, relays), channel, createdAt, initializer) + ) = build(ChannelDataNorm(name, about, picture, relays), channel, createdAt, initializer) fun build( name: String?, about: String?, picture: String?, - relays: List?, + relays: List?, channel: ETag, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, - ) = build(ChannelData(name, about, picture, relays), channel, createdAt, initializer) + ) = build(ChannelDataNorm(name, about, picture, relays), channel, createdAt, initializer) fun build( - data: ChannelData, + data: ChannelDataNorm, channel: EventHintBundle, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, @@ -84,7 +116,7 @@ class ChannelMetadataEvent( } fun build( - data: ChannelData, + data: ChannelDataNorm, channel: ETag, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMuteUserEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMuteUserEvent.kt index 2211dbd176..3d3b964034 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMuteUserEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMuteUserEvent.kt @@ -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 @@ -46,6 +46,8 @@ class ChannelMuteUserEvent( PubKeyHintProvider { override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + fun usersToMute() = tags.taggedUserIds() companion object { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/BasePublicChatEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/BasePublicChatEvent.kt index 4a76981fa4..574b20b83a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/BasePublicChatEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/BasePublicChatEvent.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/ChannelData.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/ChannelData.kt index ea1139c71a..b93684d5cc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/ChannelData.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/ChannelData.kt @@ -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,10 +20,11 @@ */ package com.vitorpamplona.quartz.nip28PublicChat.base -import android.util.Log import androidx.compose.runtime.Immutable import com.fasterxml.jackson.module.kotlin.readValue -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @Immutable data class ChannelData( @@ -34,15 +35,22 @@ data class ChannelData( ) { fun toContent() = assemble(this) - companion object { - fun parse(content: String): ChannelData? = - try { - EventMapper.mapper.readValue(content) - } catch (e: Exception) { - Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e) - ChannelData(null, null, null, null) - } + fun normalize() = ChannelDataNorm(name, about, picture, relays?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }) - fun assemble(data: ChannelData) = EventMapper.mapper.writeValueAsString(data) + companion object { + fun parse(content: String): ChannelData? = JsonMapper.mapper.readValue(content) + + fun assemble(data: ChannelData) = JsonMapper.mapper.writeValueAsString(data) } } + +data class ChannelDataNorm( + val name: String? = null, + val about: String? = null, + val picture: String? = null, + val relays: List? = null, +) { + fun denormalize() = ChannelData(name, about, picture, relays?.mapNotNull { it.url }) + + fun toContent() = denormalize().toContent() +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/IsInPublicChatChannel.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/IsInPublicChatChannel.kt index 170f3d5411..16fb326481 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/IsInPublicChatChannel.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/IsInPublicChatChannel.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/TagArrayBuilderExt.kt index cc8b85be99..10f9474bf4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListEvent.kt new file mode 100644 index 0000000000..49e72ed80d --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListEvent.kt @@ -0,0 +1,218 @@ +/** + * 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.quartz.nip28PublicChat.list + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip28PublicChat.list.tags.ChannelTag +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List +import com.vitorpamplona.quartz.nip51Lists.removeAny +import com.vitorpamplona.quartz.nip51Lists.removeParsing +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class ChannelListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider { + override fun eventHints() = tags.mapNotNull(ChannelTag::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ChannelTag::parseId) + + companion object { + const val KIND = 10005 + const val ALT = "Public Chat List" + const val FIXED_D_TAG = "" + + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) + + suspend fun create( + channel: ChannelTag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = create( + channels = listOf(channel), + isPrivate = isPrivate, + signer = signer, + createdAt = createdAt, + ) + + suspend fun create( + channels: List, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): ChannelListEvent = + if (isPrivate) { + create( + publicChannels = emptyList(), + privateChannels = channels, + signer = signer, + createdAt = createdAt, + ) + } else { + create( + publicChannels = channels, + privateChannels = emptyList(), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun add( + earlierVersion: ChannelListEvent, + channel: ChannelTag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = add( + earlierVersion = earlierVersion, + channels = listOf(channel), + isPrivate = isPrivate, + signer = signer, + createdAt = createdAt, + ) + + suspend fun add( + earlierVersion: ChannelListEvent, + channels: List, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): ChannelListEvent = + if (isPrivate) { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + tags = earlierVersion.tags, + privateTags = privateTags.removeAny(channels.map { it.toTagIdOnly() }) + channels.map { it.toTagArray() }, + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.removeAny(channels.map { it.toTagIdOnly() }) + channels.map { it.toTagArray() }, + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun remove( + earlierVersion: ChannelListEvent, + channel: ChannelTag, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): ChannelListEvent { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + return resign( + privateTags = privateTags.removeParsing(ChannelTag::parseId, channel.eventId), + tags = earlierVersion.tags.removeParsing(ChannelTag::parseId, channel.eventId), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun resign( + tags: TagArray, + privateTags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = resign( + content = PrivateTagsInContent.encryptNip04(privateTags, signer), + tags = tags, + signer = signer, + createdAt = createdAt, + ) + + suspend fun resign( + content: String, + tags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): ChannelListEvent { + val newTags = + if (tags.fastAny(AltTag::match)) { + tags + } else { + tags + AltTag.assemble(ALT) + } + + return signer.sign(createdAt, KIND, newTags, content) + } + + suspend fun create( + publicChannels: List = emptyList(), + privateChannels: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): ChannelListEvent { + val template = build(publicChannels, privateChannels, signer, createdAt) + return signer.sign(template) + } + + fun create( + publicChannels: List = emptyList(), + privateChannels: List = emptyList(), + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): ChannelListEvent { + val privateTagArray = privateChannels.map { it.toTagArray() }.toTypedArray() + val publicTagArray = publicChannels.map { it.toTagArray() }.toTypedArray() + AltTag.assemble(ALT) + return signer.signNip51List(createdAt, KIND, publicTagArray, privateTagArray) + } + + suspend fun build( + publicChannels: List = emptyList(), + privateChannels: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip04(privateChannels.map { it.toTagArray() }.toTypedArray(), signer), + createdAt = createdAt, + ) { + alt(ALT) + channels(publicChannels) + + initializer() + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/recommendations/Kind3RelayProposalSetupInfo.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/TagArrayBuilderExt.kt similarity index 62% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/recommendations/Kind3RelayProposalSetupInfo.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/TagArrayBuilderExt.kt index aa9472d0a5..ef99d88271 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/recommendations/Kind3RelayProposalSetupInfo.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/TagArrayBuilderExt.kt @@ -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,23 +18,17 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.recommendations +package com.vitorpamplona.quartz.nip28PublicChat.list -import androidx.compose.runtime.Immutable -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.relay.RelayStat +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip28PublicChat.list.tags.ChannelTag -@Immutable -data class Kind3RelayProposalSetupInfo( - val url: String, - val read: Boolean, - val write: Boolean, - val feedTypes: Set, - val relayStat: RelayStat, - val paidRelay: Boolean = false, - val users: List, -) { - val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url) -} +fun TagArrayBuilder.followChat( + eventId: HexKey, + relayUrl: NormalizedRelayUrl, +) = addUnique(ETag.assemble(eventId, relayUrl, null)) + +fun TagArrayBuilder.channels(rooms: List) = addAll(rooms.map { it.toTagArray() }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/TagArrayExt.kt new file mode 100644 index 0000000000..b0b929173e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/TagArrayExt.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip28PublicChat.list + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip28PublicChat.list.tags.ChannelTag + +fun TagArray.channels() = mapNotNull(ChannelTag::parse) + +fun TagArray.channelSet() = mapNotNullTo(mutableSetOf(), ChannelTag::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/tags/ChannelTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/tags/ChannelTag.kt new file mode 100644 index 0000000000..88aafdff84 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/tags/ChannelTag.kt @@ -0,0 +1,119 @@ +/** + * 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.quartz.nip28PublicChat.list.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.bytesUsedInMemory +import com.vitorpamplona.quartz.utils.ensure +import com.vitorpamplona.quartz.utils.pointerSizeInBytes + +@Immutable +class ChannelTag( + val eventId: HexKey, + val relay: NormalizedRelayUrl? = null, + val author: HexKey? = null, +) { + fun countMemory(): Long = + 3 * pointerSizeInBytes + // 3 fields, 4 bytes each reference (32bit) + eventId.bytesUsedInMemory() + + (relay?.url?.bytesUsedInMemory() ?: 0) + + (author?.bytesUsedInMemory() ?: 0) + + fun toNEvent(): String = NEvent.create(eventId, author, null, relay) + + fun toTagArray() = assemble(eventId, relay, author) + + fun toTagIdOnly() = assemble(eventId, null, null) + + companion object { + const val TAG_NAME = "e" + + @JvmStatic + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64 + + @JvmStatic + fun isTagged( + tag: Array, + eventId: HexKey, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == eventId + + @JvmStatic + fun parse(tag: Array): ChannelTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + + return ChannelTag(tag[1], pickRelayHint(tag), pickAuthor(tag)) + } + + @JvmStatic + fun parseId(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + return tag[1] + } + + @JvmStatic + private fun pickRelayHint(tag: Array): NormalizedRelayUrl? { + if (tag.has(2) && tag[2].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[2])) return RelayUrlNormalizer.normalizeOrNull(tag[2]) + if (tag.has(3) && tag[3].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[3])) return RelayUrlNormalizer.normalizeOrNull(tag[3]) + if (tag.has(4) && tag[4].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[4])) return RelayUrlNormalizer.normalizeOrNull(tag[4]) + return null + } + + @JvmStatic + private fun pickAuthor(tag: Array): HexKey? { + if (tag.has(2) && tag[2].length == 64) return tag[2] + if (tag.has(3) && tag[3].length == 64) return tag[3] + if (tag.has(4) && tag[4].length == 64) return tag[4] + return null + } + + @JvmStatic + fun parseAsHint(tag: Array): EventIdHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val hint = pickRelayHint(tag) + + ensure(hint != null) { return null } + + return EventIdHint(tag[1], hint) + } + + @JvmStatic + fun assemble( + eventId: HexKey, + relay: NormalizedRelayUrl?, + author: HexKey?, + ) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, author) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/message/ChannelMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/message/ChannelMessageEvent.kt index 0ae65be00e..97a0440ba6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/message/ChannelMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/message/ChannelMessageEvent.kt @@ -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,16 +24,33 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.tagArray +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag import com.vitorpamplona.quartz.nip10Notes.tags.markedETag +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip19Bech32.addressHints +import com.vitorpamplona.quartz.nip19Bech32.addressIds +import com.vitorpamplona.quartz.nip19Bech32.eventHints +import com.vitorpamplona.quartz.nip19Bech32.eventIds +import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints +import com.vitorpamplona.quartz.nip19Bech32.pubKeys import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel import com.vitorpamplona.quartz.nip28PublicChat.base.channel import com.vitorpamplona.quartz.nip28PublicChat.base.reply import com.vitorpamplona.quartz.nip37Drafts.ExposeInDraft +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -46,7 +63,59 @@ class ChannelMessageEvent( sig: HexKey, ) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig), IsInPublicChatChannel, - ExposeInDraft { + ExposeInDraft, + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider, + SearchableEvent { + override fun indexableContent() = content + + override fun eventHints(): List { + val eHints = tags.mapNotNull(MarkedETag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseEventAsHint) + val nip19Hints = citedNIP19().eventHints() + + return eHints + qHints + nip19Hints + } + + override fun linkedEventIds(): List { + val eHints = tags.mapNotNull(MarkedETag::parseId) + val qHints = tags.mapNotNull(QTag::parseEventId) + val nip19Hints = citedNIP19().eventIds() + + return eHints + qHints + nip19Hints + } + + override fun addressHints(): List { + val aHints = tags.mapNotNull(ATag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseAddressAsHint) + val nip19Hints = citedNIP19().addressHints() + + return aHints + qHints + nip19Hints + } + + override fun linkedAddressIds(): List { + val aHints = tags.mapNotNull(ATag::parseAddressId) + val qHints = tags.mapNotNull(QTag::parseAddressId) + val nip19Hints = citedNIP19().addressIds() + + return aHints + qHints + nip19Hints + } + + override fun pubKeyHints(): List { + val pHints = tags.mapNotNull(PTag::parseAsHint) + val nip19Hints = citedNIP19().pubKeyHints() + + return pHints + nip19Hints + } + + override fun linkedPubKeys(): List { + val pHints = tags.mapNotNull(PTag::parseKey) + val nip19Hints = citedNIP19().pubKeys() + + return pHints + nip19Hints + } + override fun channel() = markedRoot() ?: unmarkedRoot() override fun channelId() = channel()?.eventId diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/CustomEmoji.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/CustomEmoji.kt index 8cf13edce8..4575d0a99a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/CustomEmoji.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/CustomEmoji.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/EmojiUrlTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/EmojiUrlTag.kt index f614149177..81f32f73de 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/EmojiUrlTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/EmojiUrlTag.kt @@ -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 @@ -48,7 +48,7 @@ data class EmojiUrlTag( } fun parse(tag: Array): EmojiUrlTag? = - if (tag.size > 2 && tag[0] == "emoji") { + if (tag.size > 2 && tag[0] == TAG_NAME) { EmojiUrlTag(tag[1], tag[2]) } else { null diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/EventExt.kt index dee767b073..1dca0795c6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/EventExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/TagArrayBuilderExt.kt index 9a5067f5e8..41dab7886c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/TagArrayExt.kt index fe638929f2..bbb7a1f1b5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/TagArrayExt.kt @@ -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,6 +21,5 @@ package com.vitorpamplona.quartz.nip30CustomEmoji import com.vitorpamplona.quartz.nip01Core.core.TagArray -import com.vitorpamplona.quartz.nip01Core.core.mapTagged -fun TagArray.emojis() = this.mapTagged(EmojiUrlTag.TAG_NAME) { EmojiUrlTag.parse(it) } +fun TagArray.emojis() = this.mapNotNull(EmojiUrlTag::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/pack/EmojiPackEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/pack/EmojiPackEvent.kt index a14048f316..4e7f4d0459 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/pack/EmojiPackEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/pack/EmojiPackEvent.kt @@ -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 @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent import com.vitorpamplona.quartz.nip34Git.repository.name -import com.vitorpamplona.quartz.nip51Lists.GeneralListEvent +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.utils.TimeUtils import java.util.UUID @@ -40,7 +40,7 @@ class EmojiPackEvent( tags: Array>, content: String, sig: HexKey, -) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { companion object { const val KIND = 30030 const val ALT_DESCRIPTION = "Emoji pack" diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/selection/EmojiPackSelectionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/selection/EmojiPackSelectionEvent.kt index a49bb75161..e4073aa414 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/selection/EmojiPackSelectionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/selection/EmojiPackSelectionEvent.kt @@ -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 @@ -46,6 +46,8 @@ class EmojiPackSelectionEvent( AddressHintProvider { override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + fun emojiPacks() = tags.mapNotNull(ATag::parseAddress) fun emojiPackIds() = tags.mapNotNull(ATag::parseAddressId) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/selection/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/selection/TagArrayBuilderExt.kt index 6a702f12a1..208cf77530 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/selection/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/selection/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/AltTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/AltTag.kt index 8a7df1c4a6..910c5a1793 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/AltTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/AltTag.kt @@ -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,6 +27,9 @@ class AltTag { companion object { const val TAG_NAME = "alt" + @JvmStatic + fun match(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + @JvmStatic fun parse(tag: Array): String? { ensure(tag.has(1)) { return null } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/EventExt.kt index b23763ad48..cc50392c0b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/EventExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/TagArrayBuilderExt.kt index 87bb2b59b6..ee9047116f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/TagArrayExt.kt index a9326360a2..54c3a920e5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip31Alts/TagArrayExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/issue/GitIssueEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/issue/GitIssueEvent.kt index 063aafcf25..3e99eea631 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/issue/GitIssueEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/issue/GitIssueEvent.kt @@ -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,6 +27,9 @@ import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags @@ -34,8 +37,15 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip14Subject.SubjectTag import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip19Bech32.addressHints +import com.vitorpamplona.quartz.nip19Bech32.addressIds +import com.vitorpamplona.quartz.nip19Bech32.eventHints +import com.vitorpamplona.quartz.nip19Bech32.eventIds +import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints +import com.vitorpamplona.quartz.nip19Bech32.pubKeys import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -49,12 +59,53 @@ class GitIssueEvent( ) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig), PubKeyHintProvider, EventHintProvider, - AddressHintProvider { - override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + AddressHintProvider, + SearchableEvent { + override fun indexableContent() = "Subject: " + subject() + "\n" + content - override fun eventHints() = tags.mapNotNull(QTag::parseEventAsHint) + override fun eventHints(): List { + val qHints = tags.mapNotNull(QTag::parseEventAsHint) + val nip19Hints = citedNIP19().eventHints() - override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + tags.mapNotNull(QTag::parseAddressAsHint) + return qHints + nip19Hints + } + + override fun linkedEventIds(): List { + val qHints = tags.mapNotNull(QTag::parseEventId) + val nip19Hints = citedNIP19().eventIds() + + return qHints + nip19Hints + } + + override fun addressHints(): List { + val aHints = tags.mapNotNull(ATag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseAddressAsHint) + val nip19Hints = citedNIP19().addressHints() + + return aHints + qHints + nip19Hints + } + + override fun linkedAddressIds(): List { + val aHints = tags.mapNotNull(ATag::parseAddressId) + val qHints = tags.mapNotNull(QTag::parseAddressId) + val nip19Hints = citedNIP19().addressIds() + + return aHints + qHints + nip19Hints + } + + override fun pubKeyHints(): List { + val pHints = tags.mapNotNull(PTag::parseAsHint) + val nip19Hints = citedNIP19().pubKeyHints() + + return pHints + nip19Hints + } + + override fun linkedPubKeys(): List { + val pHints = tags.mapNotNull(PTag::parseKey) + val nip19Hints = citedNIP19().pubKeys() + + return pHints + nip19Hints + } fun repositoryHex() = tags.firstNotNullOfOrNull(ATag::parseAddressId) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/issue/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/issue/TagArrayBuilderExt.kt index 1f81b3f5d9..3371f51690 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/issue/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/issue/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/patch/GitPatchEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/patch/GitPatchEvent.kt index b9366cbd51..ab12e8be3d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/patch/GitPatchEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/patch/GitPatchEvent.kt @@ -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 @@ -48,10 +48,16 @@ class GitPatchEvent( AddressHintProvider { override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + override fun eventHints() = tags.mapNotNull(MarkedETag::parseAsHint) + override fun linkedEventIds() = tags.mapNotNull(MarkedETag::parseId) + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + private fun innerRepository() = tags.firstOrNull { it.size > 3 && it[0] == "a" && it[3] == "root" } ?: tags.firstOrNull { it.size > 1 && it[0] == "a" } @@ -101,12 +107,11 @@ class GitPatchEvent( const val KIND = 1617 const val ALT = "A Git Patch" - fun create( + suspend fun create( patch: String, createdAt: Long = TimeUtils.now(), signer: NostrSigner, - onReady: (GitPatchEvent) -> Unit, - ) { + ): GitPatchEvent { val content = patch val tags = mutableListOf( @@ -115,7 +120,7 @@ class GitPatchEvent( tags.add(AltTag.assemble(ALT)) - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + return signer.sign(createdAt, KIND, tags.toTypedArray(), content) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/reply/GitReplyEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/reply/GitReplyEvent.kt index 430b4a597a..f6483a6730 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/reply/GitReplyEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/reply/GitReplyEvent.kt @@ -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,20 +27,31 @@ import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag import com.vitorpamplona.quartz.nip10Notes.tags.markedETags import com.vitorpamplona.quartz.nip10Notes.tags.prepareMarkedETagsAsReplyTo import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip19Bech32.addressHints +import com.vitorpamplona.quartz.nip19Bech32.addressIds +import com.vitorpamplona.quartz.nip19Bech32.eventHints +import com.vitorpamplona.quartz.nip19Bech32.eventIds +import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints +import com.vitorpamplona.quartz.nip19Bech32.pubKeys import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.lastNotNullOfOrNull @Immutable +@Deprecated("Replaced by NIP-22") class GitReplyEvent( id: HexKey, pubKey: HexKey, @@ -52,11 +63,51 @@ class GitReplyEvent( PubKeyHintProvider, EventHintProvider, AddressHintProvider { - override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + override fun eventHints(): List { + val eHints = tags.mapNotNull(ETag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseEventAsHint) + val nip19Hints = citedNIP19().eventHints() - override fun eventHints() = tags.mapNotNull(MarkedETag::parseAsHint) + tags.mapNotNull(QTag::parseEventAsHint) + return eHints + qHints + nip19Hints + } - override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + tags.mapNotNull(QTag::parseAddressAsHint) + override fun linkedEventIds(): List { + val eHints = tags.mapNotNull(ETag::parseId) + val qHints = tags.mapNotNull(QTag::parseEventId) + val nip19Hints = citedNIP19().eventIds() + + return eHints + qHints + nip19Hints + } + + override fun addressHints(): List { + val aHints = tags.mapNotNull(ATag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseAddressAsHint) + val nip19Hints = citedNIP19().addressHints() + + return aHints + qHints + nip19Hints + } + + override fun linkedAddressIds(): List { + val aHints = tags.mapNotNull(ATag::parseAddressId) + val qHints = tags.mapNotNull(QTag::parseAddressId) + val nip19Hints = citedNIP19().addressIds() + + return aHints + qHints + nip19Hints + } + + override fun pubKeyHints(): List { + val pHints = tags.mapNotNull(PTag::parseAsHint) + val nip19Hints = citedNIP19().pubKeyHints() + + return pHints + nip19Hints + } + + override fun linkedPubKeys(): List { + val pHints = tags.mapNotNull(PTag::parseKey) + val nip19Hints = citedNIP19().pubKeys() + + return pHints + nip19Hints + } fun repositoryHex() = tags.firstNotNullOfOrNull(ATag::parseAddressId) @@ -68,6 +119,7 @@ class GitReplyEvent( const val KIND = 1622 const val ALT_DESCRIPTION = "A Git Reply" + @Deprecated("Replaced by NIP-22") fun reply( post: String, replyingTo: EventHintBundle, @@ -80,6 +132,7 @@ class GitReplyEvent( initializer() } + @Deprecated("Replaced by NIP-22") fun replyIssue( post: String, issue: EventHintBundle, @@ -92,6 +145,7 @@ class GitReplyEvent( initializer() } + @Deprecated("Replaced by NIP-22") fun replyPatch( post: String, patch: EventHintBundle, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/reply/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/reply/TagArrayBuilderExt.kt index 4965013299..a837fc98b0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/reply/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/reply/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/GitRepositoryEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/GitRepositoryEvent.kt index be5f5416f7..6b761c5142 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/GitRepositoryEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/GitRepositoryEvent.kt @@ -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,7 +27,6 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag import com.vitorpamplona.quartz.nip31Alts.alt -import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent.Companion.ALT_DESCRIPTION import com.vitorpamplona.quartz.nip34Git.repository.tags.CloneTag import com.vitorpamplona.quartz.nip34Git.repository.tags.DescriptionTag import com.vitorpamplona.quartz.nip34Git.repository.tags.NameTag @@ -54,7 +53,7 @@ class GitRepositoryEvent( companion object { const val KIND = 30617 - const val ALT = "Git Repository" + const val ALT_DESCRIPTION = "Git Repository" fun build( name: String, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/TagArrayBuilderExt.kt index ffd653f6b2..6b7e3abb74 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/tags/CloneTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/tags/CloneTag.kt index b4a95634e0..7cc77bacc9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/tags/CloneTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/tags/CloneTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/tags/DescriptionTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/tags/DescriptionTag.kt index faea7e0d86..b065154d4a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/tags/DescriptionTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/tags/DescriptionTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/tags/NameTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/tags/NameTag.kt index 147b52d354..17296cc6d8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/tags/NameTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/tags/NameTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/tags/WebTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/tags/WebTag.kt index 3dae53fa3f..9476d37a83 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/tags/WebTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/tags/WebTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/DefaultTrackers.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/DefaultTrackers.kt index 81fb74c6a2..3634d7d112 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/DefaultTrackers.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/DefaultTrackers.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/TagArrayBuilderExt.kt index bca45954c3..b7009a98d2 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/TorrentCommentEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/TorrentCommentEvent.kt index a5cabb1342..483a49a8d6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/TorrentCommentEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/TorrentCommentEvent.kt @@ -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,6 +27,9 @@ import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.events.ETag @@ -37,10 +40,17 @@ import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag import com.vitorpamplona.quartz.nip10Notes.tags.positionalMarkedTags import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip19Bech32.addressHints +import com.vitorpamplona.quartz.nip19Bech32.addressIds +import com.vitorpamplona.quartz.nip19Bech32.eventHints +import com.vitorpamplona.quartz.nip19Bech32.eventIds +import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints +import com.vitorpamplona.quartz.nip19Bech32.pubKeys import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils @Immutable +@Deprecated("Replaced by NIP-22") class TorrentCommentEvent( id: HexKey, pubKey: HexKey, @@ -52,11 +62,49 @@ class TorrentCommentEvent( EventHintProvider, PubKeyHintProvider, AddressHintProvider { - override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + override fun eventHints(): List { + val eHints = tags.mapNotNull(MarkedETag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseEventAsHint) + val nip19Hints = citedNIP19().eventHints() - override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + tags.mapNotNull(QTag::parseEventAsHint) + return eHints + qHints + nip19Hints + } - override fun addressHints() = tags.mapNotNull(QTag::parseAddressAsHint) + override fun linkedEventIds(): List { + val eHints = tags.mapNotNull(MarkedETag::parseId) + val qHints = tags.mapNotNull(QTag::parseEventId) + val nip19Hints = citedNIP19().eventIds() + + return eHints + qHints + nip19Hints + } + + override fun addressHints(): List { + val qHints = tags.mapNotNull(QTag::parseAddressAsHint) + val nip19Hints = citedNIP19().addressHints() + + return qHints + nip19Hints + } + + override fun linkedAddressIds(): List { + val qHints = tags.mapNotNull(QTag::parseAddressId) + val nip19Hints = citedNIP19().addressIds() + + return qHints + nip19Hints + } + + override fun pubKeyHints(): List { + val pHints = tags.mapNotNull(PTag::parseAsHint) + val nip19Hints = citedNIP19().pubKeyHints() + + return pHints + nip19Hints + } + + override fun linkedPubKeys(): List { + val pHints = tags.mapNotNull(PTag::parseKey) + val nip19Hints = citedNIP19().pubKeys() + + return pHints + nip19Hints + } fun torrent() = tags.firstNotNullOfOrNull(MarkedETag::parseRoot) ?: tags.firstNotNullOfOrNull(ETag::parse) @@ -92,6 +140,7 @@ class TorrentCommentEvent( } } + @Deprecated("Replaced by NIP-22") fun build( post: String, createdAt: Long = TimeUtils.now(), diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/TorrentEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/TorrentEvent.kt index 6d7532c4d9..5bc95dc4bd 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/TorrentEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/TorrentEvent.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/tags/BtihTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/tags/BtihTag.kt index 47feee58c8..cda984041f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/tags/BtihTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/tags/BtihTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/tags/FileTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/tags/FileTag.kt index 0639907fed..f313ee2ca7 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/tags/FileTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/tags/FileTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/tags/InfoHashTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/tags/InfoHashTag.kt index 1e0786ebf3..660a792258 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/tags/InfoHashTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/tags/InfoHashTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/tags/TrackerTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/tags/TrackerTag.kt index 4e154418e9..c482ec469c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/tags/TrackerTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip35Torrents/tags/TrackerTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/ContentWarningTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/ContentWarningTag.kt index 593bad3a70..47c1a15c6e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/ContentWarningTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/ContentWarningTag.kt @@ -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,26 +20,30 @@ */ package com.vitorpamplona.quartz.nip36SensitiveContent -import com.vitorpamplona.quartz.utils.bytesUsedInMemory -import com.vitorpamplona.quartz.utils.pointerSizeInBytes +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.arrayOfNotNull class ContentWarningTag( - val reason: String, + val reason: String? = null, ) { - fun countMemory(): Long = 1 * pointerSizeInBytes + reason.bytesUsedInMemory() - fun toTagArray() = assemble(reason) companion object { const val TAG_NAME = "content-warning" + @JvmStatic + fun isTag(tag: Array) = tag.has(0) && tag[0] == TAG_NAME + @JvmStatic fun parse(tags: Array): ContentWarningTag { require(tags[0] == TAG_NAME) - return ContentWarningTag(tags[1]) + return ContentWarningTag(tags.getOrNull(1)) } @JvmStatic - fun assemble(reason: String) = arrayOf(TAG_NAME, reason) + fun assemble() = arrayOfNotNull(TAG_NAME) + + @JvmStatic + fun assemble(reason: String?) = arrayOfNotNull(TAG_NAME, reason) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/EventExt.kt index 1262f7e76a..c44d18510c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/EventExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayBuilderExt.kt index 184b8b6bdd..fa44f876b7 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayBuilderExt.kt @@ -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,4 +23,6 @@ package com.vitorpamplona.quartz.nip36SensitiveContent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +fun TagArrayBuilder.contentWarning() = add(ContentWarningTag.assemble()) + fun TagArrayBuilder.contentWarning(reason: String) = add(ContentWarningTag.assemble(reason)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayExt.kt index dc804d02ea..e087a6aa89 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayExt.kt @@ -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,11 +21,10 @@ package com.vitorpamplona.quartz.nip36SensitiveContent import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag -fun TagArray.isSensitive() = this.any { (it.size > 0 && it[0] == ContentWarningTag.TAG_NAME) } +val nsfwTags = setOf("nsfw", "nude", "NSFW", "NUDE", "Nsfw", "Nude") -fun TagArray.isSensitiveOrNSFW() = - this.any { - (it.size > 0 && it[0] == ContentWarningTag.TAG_NAME) || - (it.size > 1 && it[0] == "t" && (it[1].equals("nsfw", true) || it[1].equals("nude", true))) - } +fun TagArray.isSensitive() = this.any(ContentWarningTag::isTag) + +fun TagArray.isSensitiveOrNSFW() = this.any { ContentWarningTag.isTag(it) || HashtagTag.isAnyTagged(it, nsfwTags) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftBuilder.kt index 64753dd769..9a6befef48 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftBuilder.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftBuilder.kt @@ -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,36 +26,29 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag import com.vitorpamplona.quartz.nip01Core.tags.kinds.kind import com.vitorpamplona.quartz.nip31Alts.alt -import com.vitorpamplona.quartz.nip37Drafts.DraftEvent.Companion.ALT_DESCRIPTION -import com.vitorpamplona.quartz.nip37Drafts.DraftEvent.Companion.KIND import com.vitorpamplona.quartz.utils.TimeUtils class DraftBuilder { companion object { - fun encryptAndSign( + suspend fun encryptAndSign( dTag: String, draft: T, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (DraftEvent) -> Unit, - ) { - signer.nip44Encrypt(draft.toJson(), signer.pubKey) { encryptedContent -> - val template = - eventTemplate(KIND, encryptedContent, createdAt) { - alt(ALT_DESCRIPTION) - dTag(dTag) - kind(draft.kind) + ): DraftEvent { + val encryptedContent = signer.nip44Encrypt(draft.toJson(), signer.pubKey) + val template = + eventTemplate(DraftEvent.KIND, encryptedContent, createdAt) { + alt(DraftEvent.ALT_DESCRIPTION) + dTag(dTag) + kind(draft.kind) - if (draft is ExposeInDraft) { - addAll(draft.exposeInDraft()) - } + if (draft is ExposeInDraft) { + addAll(draft.exposeInDraft()) } - - signer.sign(template) { - it.addToCache(signer.pubKey, draft) - onReady(it) } - } + + return signer.sign(template) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftEvent.kt index 8181069da1..b982ca9fdd 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftEvent.kt @@ -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,23 @@ */ package com.vitorpamplona.quartz.nip37Drafts +import android.util.Log import androidx.compose.runtime.Immutable +import com.fasterxml.jackson.core.JsonParseException import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address -import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag -import com.vitorpamplona.quartz.nip01Core.tags.kinds.kind +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent @@ -37,7 +44,6 @@ import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.utils.pointerSizeInBytes @Immutable class DraftEvent( @@ -47,77 +53,39 @@ class DraftEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - @Transient private var cachedInnerEvent: Map = mapOf() +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) - override fun countMemory(): Long = - super.countMemory() + - 32 + (cachedInnerEvent.values.sumOf { pointerSizeInBytes + (it?.countMemory() ?: 0) }) + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) override fun isContentEncoded() = true fun isDeleted() = content == "" - fun preCachedDraft(signer: NostrSigner): Event? = cachedInnerEvent[signer.pubKey] + fun canDecrypt(signer: NostrSigner) = signer.pubKey == pubKey - fun preCachedDraft(pubKey: HexKey): Event? = cachedInnerEvent[pubKey] + suspend fun createDeletedEvent(signer: NostrSigner): DraftEvent = signer.sign(createdAt, KIND, tags, "") - fun allCache() = cachedInnerEvent.values + suspend fun decryptInnerEvent(signer: NostrSigner): Event { + if (!canDecrypt(signer)) throw SignerExceptions.UnauthorizedDecryptionException() - fun addToCache( - pubKey: HexKey, - innerEvent: Event, - ) { - cachedInnerEvent = cachedInnerEvent + Pair(pubKey, innerEvent) - } - - fun cachedDraft( - signer: NostrSigner, - onReady: (Event) -> Unit, - ) { - cachedInnerEvent[signer.pubKey]?.let { - onReady(it) - return - } - decrypt(signer) { draft -> - addToCache(signer.pubKey, draft) - - onReady(draft) - } - } - - private fun decrypt( - signer: NostrSigner, - onReady: (Event) -> Unit, - ) { - try { - plainContent(signer) { - try { - onReady(fromJson(it)) - } catch (e: Exception) { - // Log.e("UnwrapError", "Couldn't Decrypt the content", e) - } - } - } catch (e: Exception) { - // Log.e("UnwrapError", "Couldn't Decrypt the content", e) - } - } - - private fun plainContent( - signer: NostrSigner, - onReady: (String) -> Unit, - ) { - if (content.isEmpty()) return - - signer.nip44Decrypt(content, pubKey, onReady) - } - - fun createDeletedEvent( - signer: NostrSigner, - onReady: (DraftEvent) -> Unit, - ) { - signer.sign(createdAt, KIND, tags, "") { - onReady(it) + val json = signer.nip44Decrypt(content, pubKey) + return try { + fromJson(json) + } catch (e: JsonParseException) { + Log.w("DraftEvent", "Unable to parse inner event of a draft: $json") + throw e } } @@ -130,123 +98,116 @@ class DraftEvent( dTag: String, ): String = Address.assemble(KIND, pubKey, dTag) - fun create( + @Suppress("DEPRECATION") + suspend fun create( dTag: String, originalNote: TorrentCommentEvent, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (DraftEvent) -> Unit, - ) { + ): DraftEvent { val tagsWithMarkers = originalNote.tags.filter { it.size > 3 && (it[0] == "e" || it[0] == "a") && (it[3] == "root" || it[3] == "reply") } - create(dTag, originalNote, tagsWithMarkers, signer, createdAt, onReady) + return create(dTag, originalNote, tagsWithMarkers, signer, createdAt) } - fun create( + suspend fun create( dTag: String, originalNote: InteractiveStoryBaseEvent, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (DraftEvent) -> Unit, - ) { + ): DraftEvent { val tags = mutableListOf>() - create(dTag, originalNote, tags, signer, createdAt, onReady) + return create(dTag, originalNote, tags, signer, createdAt) } - fun create( + suspend fun create( dTag: String, originalNote: LiveActivitiesChatMessageEvent, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (DraftEvent) -> Unit, - ) { + ): DraftEvent { val tags = mutableListOf>() originalNote.activity()?.let { tags.add(arrayOf("a", it.toTag(), "", "root")) } originalNote.replyingTo()?.let { tags.add(arrayOf("e", it, "", "reply")) } - create(dTag, originalNote, tags, signer, createdAt, onReady) + return create(dTag, originalNote, tags, signer, createdAt) } - fun create( + suspend fun create( dTag: String, originalNote: ChannelMessageEvent, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (DraftEvent) -> Unit, - ) { + ): DraftEvent { val tags = mutableListOf>() originalNote.channelId()?.let { tags.add(arrayOf("e", it)) } - create(dTag, originalNote, tags, signer, createdAt, onReady) + return create(dTag, originalNote, tags, signer, createdAt) } - fun create( + @Suppress("DEPRECATION") + suspend fun create( dTag: String, originalNote: GitReplyEvent, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (DraftEvent) -> Unit, - ) { + ): DraftEvent { val tags = mutableListOf>() originalNote.repository()?.let { tags.add(arrayOf("a", it.toTag())) } originalNote.replyingTo()?.let { tags.add(arrayOf("e", it)) } - create(dTag, originalNote, tags, signer, createdAt, onReady) + return create(dTag, originalNote, tags, signer, createdAt) } - fun create( + suspend fun create( dTag: String, originalNote: PollNoteEvent, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (DraftEvent) -> Unit, - ) { + ): DraftEvent { val tagsWithMarkers = originalNote.tags.filter { it.size > 3 && (it[0] == "e" || it[0] == "a") && (it[3] == "root" || it[3] == "reply") } - create(dTag, originalNote, tagsWithMarkers, signer, createdAt, onReady) + return create(dTag, originalNote, tagsWithMarkers, signer, createdAt) } - fun create( + suspend fun create( dTag: String, originalNote: CommentEvent, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (DraftEvent) -> Unit, - ) { + ): DraftEvent { val tagsWithMarkers = originalNote.rootScopes() + originalNote.directReplies() - create(dTag, originalNote, tagsWithMarkers, signer, createdAt, onReady) + return create(dTag, originalNote, tagsWithMarkers, signer, createdAt) } - fun create( + suspend fun create( dTag: String, originalNote: TextNoteEvent, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (DraftEvent) -> Unit, - ) { + ): DraftEvent { val tagsWithMarkers = originalNote.tags.filter { it.size > 3 && (it[0] == "e" || it[0] == "a") && (it[3] == "root" || it[3] == "reply") } - create(dTag, originalNote, tagsWithMarkers, signer, createdAt, onReady) + return create(dTag, originalNote, tagsWithMarkers, signer, createdAt) } - fun create( + suspend fun create( dTag: String, innerEvent: Event, anchorTagArray: List> = emptyList(), signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (DraftEvent) -> Unit, - ) { + ): DraftEvent { val tags = mutableListOf>() tags.add(arrayOf("d", dTag)) tags.add(arrayOf("k", "${innerEvent.kind}")) @@ -255,12 +216,15 @@ class DraftEvent( tags.addAll(anchorTagArray) } - signer.nip44Encrypt(innerEvent.toJson(), signer.pubKey) { encryptedContent -> - signer.sign(createdAt, KIND, tags.toTypedArray(), encryptedContent) { - it.addToCache(signer.pubKey, innerEvent) - onReady(it) - } - } + val draft = + signer.sign( + createdAt = createdAt, + kind = KIND, + tags = tags.toTypedArray(), + content = signer.nip44Encrypt(innerEvent.toJson(), signer.pubKey), + ) + + return draft } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftEventCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftEventCache.kt new file mode 100644 index 0000000000..ef14999b3e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftEventCache.kt @@ -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.quartz.nip37Drafts + +import androidx.collection.LruCache +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.caches.DecryptCache + +class DraftEventCache( + signer: NostrSigner, +) { + private val decryptionCache = + object : LruCache(1000) { + override fun create(key: DraftEvent): DraftEventDecryptCache? = + if (!key.isDeleted() && key.pubKey == signer.pubKey) { + DraftEventDecryptCache(signer) + } else { + null + } + } + + fun delete(event: DraftEvent) = decryptionCache.remove(event) + + fun preload( + event: DraftEvent, + result: Event, + ) = decryptionCache[event]?.preload(result) + + fun preCachedDraft(event: DraftEvent): Event? = decryptionCache[event]?.cached() + + suspend fun cachedDraft(event: DraftEvent) = decryptionCache[event]?.decrypt(event) +} + +class DraftEventDecryptCache( + signer: NostrSigner, +) : DecryptCache(signer) { + override suspend fun decryptAndParse( + event: DraftEvent, + signer: NostrSigner, + ): Event = event.decryptInnerEvent(signer) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/ExposeInDraft.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/ExposeInDraft.kt index 12710bb4b4..f2bdb890d5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/ExposeInDraft.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/ExposeInDraft.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/privateOutbox/PrivateOutboxRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/privateOutbox/PrivateOutboxRelayListEvent.kt new file mode 100644 index 0000000000..05a93432b7 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/privateOutbox/PrivateOutboxRelayListEvent.kt @@ -0,0 +1,121 @@ +/** + * 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.quartz.nip37Drafts.privateOutbox + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.privateRelays +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class PrivateOutboxRelayListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun publicRelays() = tags.mapNotNull(RelayTag::parse) + + suspend fun privateRelays(signer: NostrSigner) = privateTags(signer)?.mapNotNull(RelayTag::parse) + + suspend fun relays(signer: NostrSigner): List = publicRelays() + (privateRelays(signer) ?: emptyList()) + + companion object { + const val KIND = 10013 + const val FIXED_D_TAG = "" + + val ALT = "Relay list to store private content from this author" + val TAGS = arrayOf(AltTag.assemble(ALT)) + + fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG) + + fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, FIXED_D_TAG, null) + + fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG) + + suspend fun updateRelayList( + earlierVersion: PrivateOutboxRelayListEvent, + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): PrivateOutboxRelayListEvent { + val newRelayList = relays.map { RelayTag.assemble(it) } + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + + val publicTags = earlierVersion.tags.remove(RelayTag::match) + val newPrivateTags = privateTags.remove(RelayTag::notMatch).plus(newRelayList) + + return signer.signNip51List(createdAt, KIND, publicTags, newPrivateTags) + } + + suspend fun create( + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): PrivateOutboxRelayListEvent { + val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() + return signer.signNip51List(createdAt, KIND, TAGS, privateTagArray) + } + + suspend fun create( + relays: List, + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): PrivateOutboxRelayListEvent { + val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() + return signer.signNip51List(createdAt, KIND, TAGS, privateTagArray) + } + + suspend fun build( + publicRelays: List = emptyList(), + privateRelays: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip44(privateRelays.map { RelayTag.assemble(it) }.toTypedArray(), signer), + createdAt = createdAt, + ) { + alt(ALT) + privateRelays(publicRelays) + + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip38UserStatus/StatusEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip38UserStatus/StatusEvent.kt index 4ee1ba535b..17aed53f05 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip38UserStatus/StatusEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip38UserStatus/StatusEvent.kt @@ -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 @@ -41,42 +41,39 @@ class StatusEvent( companion object { const val KIND = 30315 - fun create( + suspend fun create( msg: String, type: String, expiration: Long?, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (StatusEvent) -> Unit, - ) { + ): StatusEvent { val tags = mutableListOf>() tags.add(arrayOf("d", type)) expiration?.let { tags.add(arrayOf("expiration", it.toString())) } - signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) + return signer.sign(createdAt, KIND, tags.toTypedArray(), msg) } - fun update( + suspend fun update( event: StatusEvent, newStatus: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (StatusEvent) -> Unit, - ) { + ): StatusEvent { val tags = event.tags - signer.sign(createdAt, KIND, tags, newStatus, onReady) + return signer.sign(createdAt, KIND, tags, newStatus) } - fun clear( + suspend fun clear( event: StatusEvent, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (StatusEvent) -> Unit, - ) { + ): StatusEvent { val msg = "" val tags = event.tags.filter { it.size > 1 && it[0] == "d" } - signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) + return signer.sign(createdAt, KIND, tags.toTypedArray(), msg) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/EventExt.kt index 5787f1c73b..e99565761c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/EventExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/GitHubIdentity.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/GitHubIdentity.kt index 88bbad7dd5..1d775b8cf1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/GitHubIdentity.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/GitHubIdentity.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/IdentityClaimTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/IdentityClaimTag.kt index 449100c19e..407bc37102 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/IdentityClaimTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/IdentityClaimTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/MastodonIdentity.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/MastodonIdentity.kt index 57292e8a32..f6acd2cd6f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/MastodonIdentity.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/MastodonIdentity.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/TagArrayBuilderExt.kt index 4c27d610be..4dbb92cd9e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/TelegramIdentity.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/TelegramIdentity.kt index 555196e758..a00a35ca59 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/TelegramIdentity.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/TelegramIdentity.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/TwitterIdentity.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/TwitterIdentity.kt index befdb67b50..9d00cf1c46 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/TwitterIdentity.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/TwitterIdentity.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/UnsupportedIdentity.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/UnsupportedIdentity.kt index ba55629db1..c8a570c27c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/UnsupportedIdentity.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip39ExtIdentities/UnsupportedIdentity.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/EventExt.kt index 67eba274dd..beb40d9ccb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/EventExt.kt @@ -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,15 +21,9 @@ package com.vitorpamplona.quartz.nip40Expiration import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.utils.TimeUtils -fun Event.expiration() = - try { - tags.firstOrNull { it.size > 1 && it[0] == "expiration" }?.get(1)?.toLongOrNull() - } catch (_: Exception) { - null - } +fun Event.expiration() = tags.expiration() -fun Event.isExpired() = (expiration() ?: Long.MAX_VALUE) < TimeUtils.now() +fun Event.isExpired() = tags.isExpired() -fun Event.isExpirationBefore(time: Long) = (expiration() ?: Long.MAX_VALUE) < time +fun Event.isExpirationBefore(time: Long) = tags.isExpirationBefore(time) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/ExpirationTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/ExpirationTag.kt new file mode 100644 index 0000000000..92c029f11e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/ExpirationTag.kt @@ -0,0 +1,44 @@ +/** + * 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.quartz.nip40Expiration + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +class ExpirationTag { + companion object { + const val TAG_NAME = "expiration" + + @JvmStatic + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun parse(tag: Array): Long? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toLongOrNull() + } + + @JvmStatic + fun assemble(time: Long) = arrayOfNotNull(TAG_NAME, time.toString()) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..79e46448d5 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/TagArrayBuilderExt.kt @@ -0,0 +1,26 @@ +/** + * 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.quartz.nip40Expiration + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.expiration(time: Long) = addUnique(ExpirationTag.assemble(time)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/TagArrayExt.kt new file mode 100644 index 0000000000..2ddaa8aa31 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/TagArrayExt.kt @@ -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.quartz.nip40Expiration + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.utils.TimeUtils + +fun TagArray.expiration() = this.firstNotNullOfOrNull(ExpirationTag::parse) + +fun TagArray.isExpired(): Boolean { + val exp = expiration() ?: return false + return exp < TimeUtils.now() +} + +fun TagArray.isExpirationBefore(time: Long): Boolean { + val exp = expiration() ?: return false + return exp < time +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/RelayAuthEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/RelayAuthEvent.kt index 9cf8af432b..acd65b79ab 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/RelayAuthEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/RelayAuthEvent.kt @@ -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,10 @@ package com.vitorpamplona.quartz.nip42RelayAuth import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip42RelayAuth.tags.ChallengeTag +import com.vitorpamplona.quartz.nip42RelayAuth.tags.RelayTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -35,45 +38,41 @@ class RelayAuthEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - fun relay() = tags.firstOrNull { it.size > 1 && it[0] == "relay" }?.get(1) + fun relay() = tags.firstNotNullOfOrNull(RelayTag::parse) - fun challenge() = tags.firstOrNull { it.size > 1 && it[0] == "challenge" }?.get(1) + fun challenge() = tags.firstNotNullOfOrNull(ChallengeTag::parse) companion object { const val KIND = 22242 - fun create( - relay: String, + suspend fun create( + relay: NormalizedRelayUrl, challenge: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (RelayAuthEvent) -> Unit, - ) { + ): RelayAuthEvent { val content = "" val tags = arrayOf( - arrayOf("relay", relay), - arrayOf("challenge", challenge), + RelayTag.assemble(relay), + ChallengeTag.assemble(challenge), ) - signer.sign(createdAt, KIND, tags, content, onReady) + return signer.sign(createdAt, KIND, tags, content) } - fun create( - relays: List, + suspend fun create( + relays: List, challenge: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (RelayAuthEvent) -> Unit, - ) { + ): RelayAuthEvent { val content = "" val tags = relays - .map { - arrayOf("relay", it) - }.plusElement( - arrayOf("challenge", challenge), - ).toTypedArray() - signer.sign(createdAt, KIND, tags, content, onReady) + .map { RelayTag.assemble(it) } + .plusElement(ChallengeTag.assemble(challenge)) + .toTypedArray() + return signer.sign(createdAt, KIND, tags, content) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/tags/ChallengeTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/tags/ChallengeTag.kt new file mode 100644 index 0000000000..2cf0d5fae5 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/tags/ChallengeTag.kt @@ -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.quartz.nip42RelayAuth.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ChallengeTag { + companion object { + const val TAG_NAME = "challenge" + + @JvmStatic + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + @JvmStatic + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/tags/RelayTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/tags/RelayTag.kt new file mode 100644 index 0000000000..bce30729f4 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/tags/RelayTag.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip42RelayAuth.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.ensure + +class RelayTag { + companion object { + const val TAG_NAME = "relay" + + @JvmStatic + fun match(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun notMatch(tag: Array) = tag.has(0) && tag[0] == TAG_NAME + + @JvmStatic + fun parse(tag: Array): NormalizedRelayUrl? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + val relay = RelayUrlNormalizer.normalizeOrNull(tag[1]) ?: return null + + return relay + } + + @JvmStatic + fun assemble(relay: NormalizedRelayUrl) = arrayOf(TAG_NAME, relay.url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/EncryptedInfoString.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/EncryptedInfoString.kt index c17755d031..4e232f63b4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/EncryptedInfoString.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/EncryptedInfoString.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/Nip44.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/Nip44.kt index 2ecd2b31f0..43a2b2909c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/Nip44.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/Nip44.kt @@ -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,8 +20,7 @@ */ package com.vitorpamplona.quartz.nip44Encryption -import android.util.Log -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo import com.vitorpamplona.quartz.nip04Dm.crypto.Nip04 import java.util.Base64 @@ -49,8 +48,8 @@ object Nip44 { payload: String, privateKey: ByteArray, pubKey: ByteArray, - ): String? { - if (payload.isEmpty()) return null + ): String { + require(payload.isNotBlank()) { "Payload must not be blank" } return if (payload[0] == '{') { decryptNIP44FromJackson(payload, privateKey, pubKey) } else { @@ -62,14 +61,13 @@ object Nip44 { json: String, privateKey: ByteArray, pubKey: ByteArray, - ): String? { + ): String { // Ignores if it is not a valid json val info = try { - EventMapper.mapper.readValue(json, EncryptedInfoString::class.java) + JsonMapper.mapper.readValue(json, EncryptedInfoString::class.java) } catch (e: Exception) { - Log.e("NIP44", "Unable to parse json $json") - return null + throw IllegalArgumentException("Unable to parse NIP-44 JSON: $json") } return when (info.v) { @@ -101,31 +99,25 @@ object Nip44 { v2.decrypt(encryptedInfo, privateKey, pubKey) } - else -> null + else -> throw IllegalArgumentException("Invalid or unsupported NIP-44 version code ${info.v}") } } private fun decryptNIP44FromBase64( - payload: String, + ciphertext: String, privateKey: ByteArray, pubKey: ByteArray, - ): String? { - if (payload.isEmpty()) return null + ): String { + require(ciphertext.isNotBlank()) { "ciphertext must not be blank" } // Ignores if it is not base64 - val byteArray = - try { - Base64.getDecoder().decode(payload) - } catch (e: Exception) { - Log.e("NIP44", "Unable to parse base64 $payload") - return null - } + val byteArray = Base64.getDecoder().decode(ciphertext) return when (byteArray[0].toInt()) { - EncryptedInfo.V -> Nip04.decrypt(payload, privateKey, pubKey) - Nip44v1.EncryptedInfo.V -> v1.decrypt(payload, privateKey, pubKey) - Nip44v2.EncryptedInfo.V -> v2.decrypt(payload, privateKey, pubKey) - else -> null + EncryptedInfo.V -> Nip04.decrypt(ciphertext, privateKey, pubKey) + Nip44v1.EncryptedInfo.V -> v1.decrypt(ciphertext, privateKey, pubKey) + Nip44v2.EncryptedInfo.V -> v2.decrypt(ciphertext, privateKey, pubKey) + else -> throw IllegalArgumentException("Invalid or unsupported NIP-44 version code ${byteArray[0].toInt()}") } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v1.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v1.kt index 1d13573292..d7f6824722 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v1.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v1.kt @@ -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,11 +20,11 @@ */ package com.vitorpamplona.quartz.nip44Encryption -import android.util.Log import com.vitorpamplona.quartz.utils.LibSodiumInstance import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.Secp256k1Instance import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.coroutines.CancellationException import java.util.Base64 class Nip44v1 { @@ -57,7 +57,7 @@ class Nip44v1 { ) return EncryptedInfo( - ciphertext = cipher ?: ByteArray(0), + ciphertext = cipher, nonce = nonce, ) } @@ -66,7 +66,7 @@ class Nip44v1 { payload: String, privateKey: ByteArray, pubKey: ByteArray, - ): String? { + ): String { val sharedSecret = getSharedSecret(privateKey, pubKey) return decrypt(payload, sharedSecret) } @@ -75,7 +75,7 @@ class Nip44v1 { encryptedInfo: EncryptedInfo, privateKey: ByteArray, pubKey: ByteArray, - ): String? { + ): String { val sharedSecret = getSharedSecret(privateKey, pubKey) return decrypt(encryptedInfo, sharedSecret) } @@ -83,21 +83,21 @@ class Nip44v1 { fun decrypt( payload: String, sharedSecret: ByteArray, - ): String? { - val encryptedInfo = EncryptedInfo.decodePayload(payload) ?: return null + ): String { + val encryptedInfo = EncryptedInfo.decodePayload(payload) return decrypt(encryptedInfo, sharedSecret) } fun decrypt( encryptedInfo: EncryptedInfo, sharedSecret: ByteArray, - ): String? = + ): String = LibSodiumInstance .cryptoStreamXChaCha20Xor( messageBytes = encryptedInfo.ciphertext, nonce = encryptedInfo.nonce, key = sharedSecret, - )?.decodeToString() + ).decodeToString() fun getSharedSecret( privateKey: ByteArray, @@ -127,19 +127,18 @@ class Nip44v1 { companion object { const val V: Int = 1 - fun decodePayload(payload: String): EncryptedInfo? { - return try { + fun decodePayload(payload: String): EncryptedInfo = + try { val byteArray = Base64.getDecoder().decode(payload) check(byteArray[0].toInt() == V) - return EncryptedInfo( + EncryptedInfo( nonce = byteArray.copyOfRange(1, 25), ciphertext = byteArray.copyOfRange(25, byteArray.size), ) } catch (e: Exception) { - Log.w("NIP44v1", "Unable to Parse encrypted payload: $payload") - null + if (e is CancellationException) throw e + throw IllegalStateException("NIP-44v1 Unable to Parse encrypted payload: $payload", e) } - } } fun encodePayload(): String = diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt index f4843d403d..e0ca8c2bd0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt @@ -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,12 +20,12 @@ */ package com.vitorpamplona.quartz.nip44Encryption -import android.util.Log import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip44Encryption.crypto.Hkdf import com.vitorpamplona.quartz.utils.LibSodiumInstance import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.CancellationException import java.nio.ByteBuffer import java.nio.ByteOrder import java.util.Base64 @@ -88,26 +88,26 @@ class Nip44v2 { payload: String, privateKey: ByteArray, pubKey: ByteArray, - ): String? = decrypt(payload, getConversationKey(privateKey, pubKey)) + ): String = decrypt(payload, getConversationKey(privateKey, pubKey)) fun decrypt( decoded: EncryptedInfo, privateKey: ByteArray, pubKey: ByteArray, - ): String? = decrypt(decoded, getConversationKey(privateKey, pubKey)) + ): String = decrypt(decoded, getConversationKey(privateKey, pubKey)) fun decrypt( payload: String, conversationKey: ByteArray, - ): String? { - val decoded = EncryptedInfo.decodePayload(payload) ?: return null + ): String { + val decoded = EncryptedInfo.decodePayload(payload) return decrypt(decoded, conversationKey) } fun decrypt( decoded: EncryptedInfo, conversationKey: ByteArray, - ): String? { + ): String { val messageKey = getMessageKeys(conversationKey, decoded.nonce) val calculatedMac = hmacAad(messageKey.hmacKey, decoded.ciphertext, decoded.nonce) @@ -236,7 +236,7 @@ class Nip44v2 { companion object { const val V: Int = 2 - fun decodePayload(payload: String): EncryptedInfo? { + fun decodePayload(payload: String): EncryptedInfo { check(payload.length >= 132 || payload.length <= 87472) { "Invalid payload length ${payload.length} for $payload" } @@ -245,14 +245,14 @@ class Nip44v2 { return try { val byteArray = Base64.getDecoder().decode(payload) check(byteArray[0].toInt() == V) - return EncryptedInfo( + EncryptedInfo( nonce = byteArray.copyOfRange(1, 33), ciphertext = byteArray.copyOfRange(33, byteArray.size - 32), mac = byteArray.copyOfRange(byteArray.size - 32, byteArray.size), ) } catch (e: Exception) { - Log.w("NIP44v2", "Unable to Parse encrypted payload: $payload") - null + if (e is CancellationException) throw e + throw IllegalStateException("NIP-44v2 Unable to Parse encrypted payload: $payload", e) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/SharedKeyCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/SharedKeyCache.kt index 159afd3c9e..d98d89eeb9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/SharedKeyCache.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/SharedKeyCache.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/crypto/Hkdf.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/crypto/Hkdf.kt index e52d18eb8f..263ef2efe4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/crypto/Hkdf.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip44Encryption/crypto/Hkdf.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerMessage.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerMessage.kt index 5134a79ee0..bb2823521b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerMessage.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerMessage.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequest.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequest.kt index 71a54f872b..7e84eb24da 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequest.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequest.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestConnect.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestConnect.kt index a65c6f4f17..c2cc17f1c2 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestConnect.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestConnect.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestGetPublicKey.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestGetPublicKey.kt index 27864bcfe6..89353d7719 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestGetPublicKey.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestGetPublicKey.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestGetRelays.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestGetRelays.kt index 9bb7530a58..0ec0f5bc61 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestGetRelays.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestGetRelays.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestNip04Decrypt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestNip04Decrypt.kt index 33923c9c00..3b21dad253 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestNip04Decrypt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestNip04Decrypt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestNip04Encrypt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestNip04Encrypt.kt index 71d2a6f7e5..7515513a89 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestNip04Encrypt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestNip04Encrypt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestNip44Decrypt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestNip44Decrypt.kt index f4a7e711a5..a9e14c7f3a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestNip44Decrypt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestNip44Decrypt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestNip44Encrypt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestNip44Encrypt.kt index cff4d36f67..0777eff500 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestNip44Encrypt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestNip44Encrypt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestPing.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestPing.kt index 49c45fbb80..efcefbc102 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestPing.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestPing.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestSign.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestSign.kt index e00c07af51..4629e489be 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestSign.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestSign.kt @@ -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,11 +21,12 @@ package com.vitorpamplona.quartz.nip46RemoteSigner import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import java.util.UUID class BunkerRequestSign( id: String = UUID.randomUUID().toString(), - val event: Event, + val event: EventTemplate, ) : BunkerRequest(id, METHOD_NAME, arrayOf(event.toJson())) { companion object { val METHOD_NAME = "sign_event" @@ -33,6 +34,6 @@ class BunkerRequestSign( fun parse( id: String, params: Array, - ): BunkerRequestSign = BunkerRequestSign(id, Event.fromJson(params[0])) + ): BunkerRequestSign = BunkerRequestSign(id, EventTemplate.fromJson(params[0])) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponse.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponse.kt index d41162edf2..6d04c807bc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponse.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponse.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseAck.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseAck.kt index 686b85e8c8..23000427dd 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseAck.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseAck.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseDecrypt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseDecrypt.kt index f3a3afcc4c..05296b7c09 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseDecrypt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseDecrypt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseEncrypt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseEncrypt.kt index 3323627c64..f7a579aa4c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseEncrypt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseEncrypt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseError.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseError.kt index a335e90416..af193467ce 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseError.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseError.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseEvent.kt index b59be9055b..a7df25d165 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseEvent.kt @@ -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,13 +26,12 @@ import java.util.UUID class BunkerResponseEvent( id: String = UUID.randomUUID().toString(), val event: Event, -) : com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse(id, event.toJson(), null) { +) : BunkerResponse(id, event.toJson(), null) { companion object { fun parse( id: String, result: String, error: String? = null, - ) = com.vitorpamplona.quartz.nip46RemoteSigner - .BunkerResponseEvent(id, Event.fromJson(result)) + ) = BunkerResponseEvent(id, Event.fromJson(result)) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseGetRelays.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseGetRelays.kt index eff4ce1183..6ed8c94e84 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseGetRelays.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponseGetRelays.kt @@ -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,19 +21,19 @@ package com.vitorpamplona.quartz.nip46RemoteSigner import com.fasterxml.jackson.core.type.TypeReference -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip02FollowList.ReadWrite import java.util.UUID class BunkerResponseGetRelays( id: String = UUID.randomUUID().toString(), val relays: Map, -) : BunkerResponse(id, EventMapper.mapper.writeValueAsString(relays), null) { +) : BunkerResponse(id, JsonMapper.mapper.writeValueAsString(relays), null) { companion object { fun parse( id: String, result: String, error: String? = null, - ) = BunkerResponseGetRelays(id, EventMapper.mapper.readValue(result, object : TypeReference>() {})) + ) = BunkerResponseGetRelays(id, JsonMapper.mapper.readValue(result, object : TypeReference>() {})) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponsePong.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponsePong.kt index 1f1de75083..8db9935964 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponsePong.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponsePong.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponsePublicKey.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponsePublicKey.kt index 4fc04c5d18..62acb69491 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponsePublicKey.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponsePublicKey.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectEvent.kt index b15b53830b..cc07e06850 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectEvent.kt @@ -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,12 +23,12 @@ package com.vitorpamplona.quartz.nip46RemoteSigner import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.utils.pointerSizeInBytes @Immutable class NostrConnectEvent( @@ -39,17 +39,18 @@ class NostrConnectEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - @Transient private var decryptedContent: Map = mapOf() - - override fun countMemory(): Long = - super.countMemory() + - pointerSizeInBytes + (decryptedContent.values.sumOf { pointerSizeInBytes + it.countMemory() }) - override fun isContentEncoded() = true - private fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1) + fun canDecrypt(signer: NostrSigner) = pubKey == signer.pubKey || recipientPubKey() == signer.pubKey - fun recipientPubKeyBytes() = recipientPubKey()?.runCatching { Hex.decode(this) }?.getOrNull() + suspend fun decryptMessage(signer: NostrSigner): BunkerMessage { + if (!canDecrypt(signer)) throw SignerExceptions.UnauthorizedDecryptionException() + + val retVal = signer.decrypt(content, talkingWith(signer.pubKey)) + return JsonMapper.mapper.readValue(retVal, BunkerMessage::class.java) + } + + private fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1) fun verifiedRecipientPubKey(): HexKey? { val recipient = recipientPubKey() @@ -62,47 +63,29 @@ class NostrConnectEvent( fun talkingWith(oneSideHex: String): HexKey = if (pubKey == oneSideHex) verifiedRecipientPubKey() ?: pubKey else pubKey - fun plainContent( - signer: NostrSigner, - onReady: (BunkerMessage) -> Unit, - ) { - decryptedContent[signer.pubKey]?.let { - onReady(it) - return - } - - // decrypts using NIP-04 or NIP-44 - signer.decrypt(content, talkingWith(signer.pubKey)) { retVal -> - val content = EventMapper.mapper.readValue(retVal, BunkerMessage::class.java) - - decryptedContent = decryptedContent + Pair(signer.pubKey, content) - - onReady(content) - } - } - companion object { const val KIND = 24133 const val ALT = "Nostr Connect Event" - fun create( + suspend fun create( message: BunkerMessage, remoteKey: HexKey, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (NostrConnectEvent) -> Unit, - ) { - val tags = - arrayOf( - AltTag.assemble(ALT), - arrayOf("p", remoteKey), - ) - - val encrypted = EventMapper.mapper.writeValueAsString(message) - - signer.nip44Encrypt(encrypted, remoteKey) { content -> - signer.sign(createdAt, KIND, tags, content, onReady) - } - } + ): NostrConnectEvent = + signer.sign( + createdAt = createdAt, + kind = KIND, + tags = + arrayOf( + AltTag.assemble(ALT), + arrayOf("p", remoteKey), + ), + content = + signer.nip44Encrypt( + plaintext = JsonMapper.mapper.writeValueAsString(message), + toPublicKey = remoteKey, + ), + ) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEvent.kt index 0b4676420a..919bc33a89 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEvent.kt @@ -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.quartz.nip47WalletConnect -import android.util.Log import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.utils.bytesUsedInMemory -import com.vitorpamplona.quartz.utils.pointerSizeInBytes @Immutable class LnZapPaymentRequestEvent( @@ -40,60 +38,41 @@ class LnZapPaymentRequestEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - // Once one of an app user decrypts the payment, all users else can see it. - @Transient private var lnInvoice: String? = null - - override fun countMemory(): Long = - super.countMemory() + - pointerSizeInBytes + (lnInvoice?.bytesUsedInMemory() ?: 0) // rough calculation + override fun isContentEncoded() = true fun walletServicePubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1) fun talkingWith(oneSideHex: String): HexKey = if (pubKey == oneSideHex) walletServicePubKey() ?: pubKey else pubKey - fun lnInvoice( - signer: NostrSigner, - onReady: (String) -> Unit, - ) { - lnInvoice?.let { - onReady(it) - return - } + fun canDecrypt(signer: NostrSigner) = pubKey == signer.pubKey || walletServicePubKey() == signer.pubKey - try { - signer.decrypt(content, talkingWith(signer.pubKey)) { jsonText -> - val payInvoiceMethod = EventMapper.mapper.readValue(jsonText, Request::class.java) - - lnInvoice = (payInvoiceMethod as? PayInvoiceMethod)?.params?.invoice - - lnInvoice?.let { onReady(it) } - } - } catch (e: Exception) { - Log.w("BookmarkList", "Error decrypting the message ${e.message}") - } + suspend fun decryptRequest(signer: NostrSigner): Request { + if (!canDecrypt(signer)) throw SignerExceptions.UnauthorizedDecryptionException() + val jsonText = signer.decrypt(content, talkingWith(signer.pubKey)) + return JsonMapper.mapper.readValue(jsonText, Request::class.java) } companion object { const val KIND = 23194 const val ALT = "Zap payment request" - fun create( + suspend fun create( lnInvoice: String, walletServicePubkey: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (LnZapPaymentRequestEvent) -> Unit, - ) { - val serializedRequest = EventMapper.mapper.writeValueAsString(PayInvoiceMethod.create(lnInvoice)) + ): LnZapPaymentRequestEvent { + val serializedRequest = JsonMapper.mapper.writeValueAsString(PayInvoiceMethod.create(lnInvoice)) val tags = arrayOf(arrayOf("p", walletServicePubkey), AltTag.assemble(ALT)) - signer.nip04Encrypt( - serializedRequest, - walletServicePubkey, - ) { content -> - signer.sign(createdAt, KIND, tags, content, onReady) - } + val encrypted = + signer.nip04Encrypt( + serializedRequest, + walletServicePubkey, + ) + + return signer.sign(createdAt, KIND, tags, encrypted) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentResponseEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentResponseEvent.kt index 87f5a3e888..1f019bac67 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentResponseEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentResponseEvent.kt @@ -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,13 +20,12 @@ */ package com.vitorpamplona.quartz.nip47WalletConnect -import android.util.Log import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.utils.pointerSizeInBytes +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions @Immutable class LnZapPaymentResponseEvent( @@ -37,10 +36,7 @@ class LnZapPaymentResponseEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - // Once one of an app user decrypts the payment, all users else can see it. - @Transient private var response: Response? = null - - override fun countMemory(): Long = super.countMemory() + pointerSizeInBytes + (response?.countMemory() ?: 0) + override fun isContentEncoded() = true fun requestAuthor() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1) @@ -48,38 +44,13 @@ class LnZapPaymentResponseEvent( fun talkingWith(oneSideHex: String): HexKey = if (pubKey == oneSideHex) requestAuthor() ?: pubKey else pubKey - private fun plainContent( - signer: NostrSigner, - onReady: (String) -> Unit, - ) { - try { - signer.decrypt(content, talkingWith(signer.pubKey)) { content -> onReady(content) } - } catch (e: Exception) { - Log.w("PrivateDM", "Error decrypting the message ${e.message}") - } - } + fun canDecrypt(signer: NostrSigner) = pubKey == signer.pubKey || requestAuthor() == signer.pubKey - fun response( - signer: NostrSigner, - onReady: (Response) -> Unit, - ) { - response?.let { - onReady(it) - return - } + suspend fun decrypt(signer: NostrSigner): Response { + if (!canDecrypt(signer)) throw SignerExceptions.UnauthorizedDecryptionException() - try { - if (content.isNotEmpty()) { - plainContent(signer) { - EventMapper.mapper.readValue(it, Response::class.java)?.let { - response = it - onReady(it) - } - } - } - } catch (e: Exception) { - Log.w("LnZapPaymentResponseEvent", "Can't parse content as a payment response: $content", e) - } + val json = signer.decrypt(content, talkingWith(signer.pubKey)) + return JsonMapper.mapper.readValue(json, Response::class.java) } companion object { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt index a9f52689c8..c3cf1add28 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt @@ -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,19 +20,23 @@ */ package com.vitorpamplona.quartz.nip47WalletConnect -import android.net.Uri +import androidx.core.net.toUri +import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey import kotlinx.coroutines.CancellationException // Rename to the corect nip number when ready. class Nip47WalletConnect { companion object { - fun parse(uri: String): Nip47URI { + fun parse(uri: String): Nip47URINorm { // nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&metadata=%7B%22name%22%3A%22Example%22%7D - val url = Uri.parse(uri) + val url = uri.toUri() if (url.scheme != "nostrwalletconnect" && url.scheme != "nostr+walletconnect") { throw IllegalArgumentException("Not a Wallet Connect QR Code") @@ -49,9 +53,10 @@ class Nip47WalletConnect { } val relay = url.getQueryParameter("relay") ?: throw IllegalArgumentException("Relay cannot be null") + val relayNorm = RelayUrlNormalizer.normalizeOrNull(relay) ?: throw IllegalArgumentException("Invalid relay Url") val secret = url.getQueryParameter("secret") - return Nip47URI(pubkeyHex, relay, secret) + return Nip47URINorm(pubkeyHex, relayNorm, secret) } } @@ -59,5 +64,28 @@ class Nip47WalletConnect { val pubKeyHex: HexKey, val relayUri: String, val secret: HexKey?, - ) + ) { + fun normalize(): Nip47URINorm? = + RelayUrlNormalizer.normalizeOrNull(relayUri)?.let { + Nip47URINorm( + pubKeyHex, + it, + secret, + ) + } + + companion object { + fun parser(json: String) = JsonMapper.mapper.readValue(json) + + fun serializer(value: Nip47URI) = JsonMapper.mapper.writeValueAsString(value) + } + } + + data class Nip47URINorm( + val pubKeyHex: HexKey, + val relayUri: NormalizedRelayUrl, + val secret: HexKey?, + ) { + fun denormalize(): Nip47URI? = Nip47URI(pubKeyHex, relayUri.url, secret) + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/NostrWalletConnectRequestCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/NostrWalletConnectRequestCache.kt new file mode 100644 index 0000000000..890fce7ac5 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/NostrWalletConnectRequestCache.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect + +import androidx.collection.LruCache +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.caches.DecryptCache + +class NostrWalletConnectRequestCache( + signer: NostrSigner, +) { + private val decryptionCache = + object : LruCache(50) { + override fun create(key: LnZapPaymentRequestEvent): NWCRequestDecryptCache? = + if (key.content.isNotBlank() && key.canDecrypt(signer)) { + NWCRequestDecryptCache(signer) + } else { + null + } + } + + fun cachedRequest(event: LnZapPaymentRequestEvent): Request? = decryptionCache[event]?.cached() + + suspend fun decryptRequest(event: LnZapPaymentRequestEvent) = decryptionCache[event]?.decrypt(event) +} + +class NWCRequestDecryptCache( + signer: NostrSigner, +) : DecryptCache(signer) { + override suspend fun decryptAndParse( + event: LnZapPaymentRequestEvent, + signer: NostrSigner, + ) = event.decryptRequest(signer) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/NostrWalletConnectResponseCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/NostrWalletConnectResponseCache.kt new file mode 100644 index 0000000000..d4b31fb67f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/NostrWalletConnectResponseCache.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect + +import androidx.collection.LruCache +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.caches.DecryptCache + +class NostrWalletConnectResponseCache( + signer: NostrSigner, +) { + private val decryptionCache = + object : LruCache(50) { + override fun create(key: LnZapPaymentResponseEvent): NWCResponseDecryptCache? = + if (key.content.isNotBlank() && key.canDecrypt(signer)) { + NWCResponseDecryptCache(signer) + } else { + null + } + } + + fun cachedResponse(event: LnZapPaymentResponseEvent): Response? = decryptionCache[event]?.cached() + + suspend fun decryptResponse(event: LnZapPaymentResponseEvent) = decryptionCache[event]?.decrypt(event) +} + +class NWCResponseDecryptCache( + signer: NostrSigner, +) : DecryptCache(signer) { + override suspend fun decryptAndParse( + event: LnZapPaymentResponseEvent, + signer: NostrSigner, + ) = event.decrypt(signer) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt index afab8cb37f..1bb56fc6e0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/RequestDeserializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/RequestDeserializer.kt index a4d511f474..ce73d51e5a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/RequestDeserializer.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/RequestDeserializer.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/Response.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/Response.kt index 85c10c4b58..b895114ed7 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/Response.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/Response.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/ResponseDeserializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/ResponseDeserializer.kt index 2b53eef7b7..c9c3c1c593 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/ResponseDeserializer.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/ResponseDeserializer.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip48ProxyTags/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip48ProxyTags/EventExt.kt index 4398e0adb3..870e1bfa98 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip48ProxyTags/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip48ProxyTags/EventExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip48ProxyTags/ProxyTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip48ProxyTags/ProxyTag.kt index b11c0e4f2f..44496d1ea2 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip48ProxyTags/ProxyTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip48ProxyTags/ProxyTag.kt @@ -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,7 +21,9 @@ package com.vitorpamplona.quartz.nip48ProxyTags import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.utils.bytesUsedInMemory +import com.vitorpamplona.quartz.utils.ensure import com.vitorpamplona.quartz.utils.pointerSizeInBytes @Immutable @@ -37,8 +39,12 @@ data class ProxyTag( const val TAG_NAME = "proxy" @JvmStatic - fun parse(tags: Array): ProxyTag { - require(tags[0] == TAG_NAME) + fun isTagged(tag: Array) = tag.has(2) && tag[0] == TAG_NAME && tag[1].isNotEmpty() && tag[2].isNotEmpty() + + @JvmStatic + fun parse(tags: Array): ProxyTag? { + ensure(tags.has(2)) { return null } + ensure(tags[0] == TAG_NAME) { return null } return ProxyTag(tags[1], tags[2]) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip48ProxyTags/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip48ProxyTags/TagArrayBuilderExt.kt index 3c21b34897..e0070ad983 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip48ProxyTags/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip48ProxyTags/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip48ProxyTags/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip48ProxyTags/TagArrayExt.kt index ac1b87518f..8b8056a8df 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip48ProxyTags/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip48ProxyTags/TagArrayExt.kt @@ -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,12 +21,11 @@ package com.vitorpamplona.quartz.nip48ProxyTags import com.vitorpamplona.quartz.nip01Core.core.TagArray -import com.vitorpamplona.quartz.nip01Core.core.firstTagValue -import com.vitorpamplona.quartz.nip01Core.core.hasTagWithContent -import com.vitorpamplona.quartz.nip01Core.core.mapValues +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull -fun TagArray.proxies() = this.mapValues(ProxyTag.TAG_NAME) +fun TagArray.proxies() = this.mapNotNull(ProxyTag::parse) -fun TagArray.firstProxy() = this.firstTagValue(ProxyTag.TAG_NAME) +fun TagArray.firstProxy() = this.fastFirstNotNullOfOrNull(ProxyTag::parse) -fun TagArray.hasProxy() = this.hasTagWithContent(ProxyTag.TAG_NAME) +fun TagArray.hasProxy() = this.fastAny(ProxyTag::isTagged) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip49PrivKeyEnc/ByteArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip49PrivKeyEnc/ByteArrayExt.kt index ae6a339285..a211b92592 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip49PrivKeyEnc/ByteArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip49PrivKeyEnc/ByteArrayExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49.kt index b3f0575aad..66421f5ef0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip50Search/SearchRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip50Search/SearchRelayListEvent.kt index d439328699..6dd52abc33 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip50Search/SearchRelayListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip50Search/SearchRelayListEvent.kt @@ -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,14 +21,27 @@ package com.vitorpamplona.quartz.nip50Search import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relays +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.searchRelays +import com.vitorpamplona.quartz.nip51Lists.remove import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.plus +import kotlin.collections.toTypedArray @Immutable class SearchRelayListEvent( @@ -38,73 +51,72 @@ class SearchRelayListEvent( tags: Array>, content: String, sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun relays(): List = - tags.mapNotNull { - if (it.size > 1 && it[0] == "relay") { - it[1] - } else { - null - } - } +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun publicRelays() = tags.relays() + + suspend fun privateRelays(signer: NostrSigner) = privateTags(signer)?.relays() + + suspend fun relays(signer: NostrSigner): List = publicRelays() + (privateRelays(signer) ?: emptyList()) companion object { const val KIND = 10007 + val ALT = "Relay list to use for Search" + val ALT_TAG = arrayOf(AltTag.assemble(ALT)) - fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG) + fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey) - fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, FIXED_D_TAG, null) + fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey) - fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG) + fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey) - fun createTagArray(relays: List): Array> = - relays - .map { - arrayOf("relay", it) - }.plusElement(AltTag.assemble("Relay list to use for Search")) - .toTypedArray() - - fun updateRelayList( + suspend fun updateRelayList( earlierVersion: SearchRelayListEvent, - relays: List, + relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (SearchRelayListEvent) -> Unit, - ) { - val tags = - earlierVersion.tags - .filter { it[0] != "relay" } - .plus( - relays.map { - arrayOf("relay", it) - }, - ).toTypedArray() + ): SearchRelayListEvent { + val newRelayList = relays.map { RelayTag.assemble(it) } + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() - signer.sign(createdAt, KIND, tags, earlierVersion.content, onReady) + val publicTags = earlierVersion.tags.remove(RelayTag::match) + val newPrivateTags = privateTags.remove(RelayTag::notMatch).plus(newRelayList) + + return signer.signNip51List(createdAt, KIND, publicTags, newPrivateTags) } - fun createFromScratch( - relays: List, + suspend fun create( + relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (SearchRelayListEvent) -> Unit, - ) { - create(relays, signer, createdAt, onReady) + ): SearchRelayListEvent { + val publicTagArray = relays.map { RelayTag.assemble(it) }.plus(ALT_TAG).toTypedArray() + return signer.signNip51List(createdAt, KIND, publicTagArray, emptyArray()) } fun create( - relays: List, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (SearchRelayListEvent) -> Unit, - ) { - signer.sign(createdAt, KIND, createTagArray(relays), "", onReady) - } - - fun create( - relays: List, + relays: List, signer: NostrSignerSync, createdAt: Long = TimeUtils.now(), - ): SearchRelayListEvent? = signer.sign(createdAt, KIND, createTagArray(relays), "") + ): SearchRelayListEvent { + val publicTagArray = relays.map { RelayTag.assemble(it) }.plus(ALT_TAG).toTypedArray() + return signer.signNip51List(createdAt, KIND, publicTagArray, emptyArray()) + } + + suspend fun build( + publicRelays: List = emptyList(), + privateRelays: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip44(privateRelays.map { RelayTag.assemble(it) }.toTypedArray(), signer), + createdAt = createdAt, + ) { + alt(ALT) + searchRelays(publicRelays) + + initializer() + } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip50Search/SearchableEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip50Search/SearchableEvent.kt new file mode 100644 index 0000000000..e583e4ab0f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip50Search/SearchableEvent.kt @@ -0,0 +1,25 @@ +/** + * 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.quartz.nip50Search + +interface SearchableEvent { + fun indexableContent(): String +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/BookmarkListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/BookmarkListEvent.kt deleted file mode 100644 index b4b57b8752..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/BookmarkListEvent.kt +++ /dev/null @@ -1,237 +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.quartz.nip51Lists - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip01Core.tags.events.ETag -import com.vitorpamplona.quartz.nip31Alts.AltTag -import com.vitorpamplona.quartz.utils.TimeUtils - -@Immutable -class BookmarkListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun countBookmarks() = tags.count(ETag::isTagged) + tags.count(ATag::isTagged) - - companion object { - const val KIND = 30001 - const val ALT = "List of bookmarks" - const val DEFAULT_D_TAG_BOOKMARKS = "bookmark" - - fun addEvent( - earlierVersion: BookmarkListEvent?, - eventId: HexKey, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit, - ) = addTag(earlierVersion, "e", eventId, isPrivate, signer, createdAt, onReady) - - fun addReplaceable( - earlierVersion: BookmarkListEvent?, - aTag: ATag, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit, - ) = addTag(earlierVersion, "a", aTag.toTag(), isPrivate, signer, createdAt, onReady) - - fun addTag( - earlierVersion: BookmarkListEvent?, - tagName: String, - tagValue: HexKey, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit, - ) { - add( - earlierVersion, - arrayOf(arrayOf(tagName, tagValue)), - isPrivate, - signer, - createdAt, - onReady, - ) - } - - fun add( - earlierVersion: BookmarkListEvent?, - listNewTags: Array>, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit, - ) { - if (isPrivate) { - if (earlierVersion != null) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = privateTags.plus(listNewTags), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = earlierVersion.tags, - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } else { - encryptTags( - privateTags = listNewTags, - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = arrayOf(arrayOf("d", DEFAULT_D_TAG_BOOKMARKS)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } else { - create( - content = earlierVersion?.content ?: "", - tags = (earlierVersion?.tags ?: arrayOf(arrayOf("d", DEFAULT_D_TAG_BOOKMARKS))).plus(listNewTags), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - - fun removeEvent( - earlierVersion: BookmarkListEvent, - eventId: HexKey, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit, - ) = removeTag(earlierVersion, "e", eventId, isPrivate, signer, createdAt, onReady) - - fun removeReplaceable( - earlierVersion: BookmarkListEvent, - aTag: ATag, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit, - ) = removeTag(earlierVersion, "a", aTag.toTag(), isPrivate, signer, createdAt, onReady) - - private fun removeTag( - earlierVersion: BookmarkListEvent, - tagName: String, - tagValue: HexKey, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit, - ) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = - privateTags - .filter { it.size <= 1 || !(it[0] == tagName && it[1] == tagValue) } - .toTypedArray(), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = - earlierVersion.tags - .filter { it.size <= 1 || !(it[0] == tagName && it[1] == tagValue) } - .toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } else { - create( - content = earlierVersion.content, - tags = - earlierVersion.tags - .filter { it.size <= 1 || !(it[0] == tagName && it[1] == tagValue) } - .toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - - fun create( - content: String, - tags: Array>, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit, - ) { - val newTags = - if (tags.any { it.size > 1 && it[0] == "alt" }) { - tags - } else { - tags + AltTag.assemble(ALT) - } - - signer.sign(createdAt, KIND, newTags, content, onReady) - } - - fun create( - name: String = "", - events: List? = null, - users: List? = null, - addresses: List? = null, - privEvents: List? = null, - privUsers: List? = null, - privAddresses: List? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit, - ) { - val tags = mutableListOf>() - tags.add(arrayOf("d", name)) - - events?.forEach { tags.add(arrayOf("e", it)) } - users?.forEach { tags.add(arrayOf("p", it)) } - addresses?.forEach { tags.add(arrayOf("a", it.toTag())) } - tags.add(AltTag.assemble(ALT)) - - createPrivateTags(privEvents, privUsers, privAddresses, signer) { content -> - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) - } - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/GeneralListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/GeneralListEvent.kt deleted file mode 100644 index 4905d90c42..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/GeneralListEvent.kt +++ /dev/null @@ -1,178 +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.quartz.nip51Lists - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.isTagged -import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider -import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider -import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address -import com.vitorpamplona.quartz.nip01Core.tags.events.ETag -import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag -import com.vitorpamplona.quartz.nip01Core.tags.people.PTag -import com.vitorpamplona.quartz.nip51Lists.tags.NameTag -import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag -import kotlinx.collections.immutable.ImmutableSet -import kotlinx.collections.immutable.toImmutableSet - -@Immutable -abstract class GeneralListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, kind, tags, content, sig), - EventHintProvider, - AddressHintProvider, - PubKeyHintProvider { - override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + (cachedPrivateTags()?.mapNotNull(ETag::parseAsHint) ?: emptyList()) - - override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + (cachedPrivateTags()?.mapNotNull(ATag::parseAsHint) ?: emptyList()) - - override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + (cachedPrivateTags()?.mapNotNull(PTag::parseAsHint) ?: emptyList()) - - fun name() = tags.firstNotNullOfOrNull(NameTag::parse) - - @Deprecated("NIP-51 has deprecated Title. Use name instead", ReplaceWith("name()")) - fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) - - fun nameOrTitle() = name() ?: title() - - fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1) - - fun filterTagList( - key: String, - privateTags: Array>?, - ): ImmutableSet { - val result = HashSet(tags.size + (privateTags?.size ?: 0)) - - privateTags?.let { it.filter { it.size > 1 && it[0] == key }.mapTo(result) { it[1] } } - - tags.filter { it.size > 1 && it[0] == key }.mapTo(result) { it[1] } - - return result.toImmutableSet() - } - - fun isTagged( - key: String, - tag: String, - isPrivate: Boolean, - signer: NostrSigner, - onReady: (Boolean) -> Unit, - ) = if (isPrivate) { - privateTagsOrEmpty(signer = signer) { - onReady( - it.any { it.size > 1 && it[0] == key && it[1] == tag }, - ) - } - } else { - onReady(tags.isTagged(key, tag)) - } - - fun privateTagsOrEmpty( - signer: NostrSigner, - onReady: (Array>) -> Unit, - ) { - privateTags(signer, onReady) - } - - fun privateTaggedUsers( - signer: NostrSigner, - onReady: (List) -> Unit, - ) = privateTags(signer) { onReady(filterUsers(it)) } - - fun privateHashtags( - signer: NostrSigner, - onReady: (List) -> Unit, - ) = privateTags(signer) { onReady(filterHashtags(it)) } - - fun privateGeohashes( - signer: NostrSigner, - onReady: (List) -> Unit, - ) = privateTags(signer) { onReady(filterGeohashes(it)) } - - fun privateTaggedEvents( - signer: NostrSigner, - onReady: (List) -> Unit, - ) = privateTags(signer) { onReady(filterEvents(it)) } - - fun privateATags( - signer: NostrSigner, - onReady: (List) -> Unit, - ) = privateTags(signer) { onReady(filterATags(it)) } - - fun privateAddress( - signer: NostrSigner, - onReady: (List
) -> Unit, - ) = privateTags(signer) { onReady(filterAddresses(it)) } - - fun filterUsers(tags: Array>): List = tags.mapNotNull(PTag::parseKey) - - fun filterHashtags(tags: Array>): List = tags.mapNotNull(HashtagTag::parse) - - fun filterGeohashes(tags: Array>): List = tags.geohashes() - - fun filterEvents(tags: Array>): List = tags.mapNotNull(ETag::parseId) - - fun filterATags(tags: Array>): List = tags.mapNotNull(ATag::parse) - - fun filterAddresses(tags: Array>): List
= tags.mapNotNull(ATag::parseAddress) - - companion object { - fun createPrivateTags( - privEvents: List? = null, - privUsers: List? = null, - privAddresses: List? = null, - signer: NostrSigner, - onReady: (String) -> Unit, - ) { - val privTags = mutableListOf>() - privEvents?.forEach { privTags.add(arrayOf("e", it)) } - privUsers?.forEach { privTags.add(arrayOf("p", it)) } - privAddresses?.forEach { privTags.add(arrayOf("a", it.toTag())) } - - return encryptTags(privTags.toTypedArray(), signer, onReady) - } - - fun encryptTags( - privateTags: Array>? = null, - signer: NostrSigner, - onReady: (String) -> Unit, - ) { - val msg = EventMapper.mapper.writeValueAsString(privateTags) - - signer.nip04Encrypt( - msg, - signer.pubKey, - onReady, - ) - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/MuteListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/MuteListEvent.kt deleted file mode 100644 index 844026e78e..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/MuteListEvent.kt +++ /dev/null @@ -1,266 +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.quartz.nip51Lists - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address -import com.vitorpamplona.quartz.nip31Alts.AltTag -import com.vitorpamplona.quartz.utils.TimeUtils - -@Immutable -class MuteListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - override fun dTag() = FIXED_D_TAG - - fun publicAndPrivateUsersAndWords( - signer: NostrSigner, - onReady: (PeopleListEvent.UsersAndWords) -> Unit, - ) { - privateTagsOrEmpty(signer) { - onReady( - PeopleListEvent.UsersAndWords(filterTagList("p", it), filterTagList("word", it)), - ) - } - } - - companion object { - const val KIND = 10000 - const val FIXED_D_TAG = "" - const val ALT = "Mute List" - - fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) - - fun blockListFor(pubKeyHex: HexKey): String = "10000:$pubKeyHex:" - - fun createListWithTag( - key: String, - tag: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit, - ) { - if (isPrivate) { - encryptTags(arrayOf(arrayOf(key, tag)), signer) { encryptedTags -> - create( - content = encryptedTags, - tags = emptyArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } else { - create( - content = "", - tags = arrayOf(arrayOf(key, tag)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - - fun createListWithUser( - pubKeyHex: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit, - ) = createListWithTag("p", pubKeyHex, isPrivate, signer, createdAt, onReady) - - fun createListWithWord( - word: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit, - ) = createListWithTag("word", word, isPrivate, signer, createdAt, onReady) - - fun addUsers( - earlierVersion: MuteListEvent, - listPubKeyHex: List, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit, - ) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = - privateTags.plus( - listPubKeyHex.map { arrayOf("p", it) }, - ), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = earlierVersion.tags, - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } else { - create( - content = earlierVersion.content, - tags = - earlierVersion.tags.plus( - listPubKeyHex.map { arrayOf("p", it) }, - ), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - - fun addWord( - earlierVersion: MuteListEvent, - word: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit, - ) = addTag(earlierVersion, "word", word, isPrivate, signer, createdAt, onReady) - - fun addUser( - earlierVersion: MuteListEvent, - pubKeyHex: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit, - ) = addTag(earlierVersion, "p", pubKeyHex, isPrivate, signer, createdAt, onReady) - - fun addTag( - earlierVersion: MuteListEvent, - key: String, - tag: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit, - ) { - earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> - if (!isTagged) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = privateTags.plus(element = arrayOf(key, tag)), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = earlierVersion.tags, - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } else { - create( - content = earlierVersion.content, - tags = earlierVersion.tags.plus(element = arrayOf(key, tag)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } - } - - fun removeWord( - earlierVersion: MuteListEvent, - word: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit, - ) = removeTag(earlierVersion, "word", word, signer, createdAt, onReady) - - fun removeUser( - earlierVersion: MuteListEvent, - pubKeyHex: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit, - ) = removeTag(earlierVersion, "p", pubKeyHex, signer, createdAt, onReady) - - fun removeTag( - earlierVersion: MuteListEvent, - key: String, - tag: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit, - ) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = - privateTags - .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } - .toTypedArray(), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = - earlierVersion.tags - .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } - .toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } - - fun create( - content: String, - tags: Array>, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit, - ) { - val newTags = - if (tags.any { it.size > 1 && it[0] == "alt" }) { - tags - } else { - tags + AltTag.assemble(ALT) - } - - signer.sign(createdAt, KIND, newTags, content, onReady) - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PeopleListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PeopleListEvent.kt deleted file mode 100644 index 6cd6ad7e96..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PeopleListEvent.kt +++ /dev/null @@ -1,290 +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.quartz.nip51Lists - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address -import com.vitorpamplona.quartz.nip31Alts.AltTag -import com.vitorpamplona.quartz.utils.TimeUtils - -@Immutable -class PeopleListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - @Immutable - class UsersAndWords( - val users: Set = setOf(), - val words: Set = setOf(), - ) - - fun publicAndPrivateUsersAndWords( - signer: NostrSigner, - onReady: (UsersAndWords) -> Unit, - ) { - privateTagsOrEmpty(signer) { - onReady( - UsersAndWords( - filterTagList("p", it), - filterTagList("word", it), - ), - ) - } - } - - fun isTaggedWord( - word: String, - isPrivate: Boolean, - signer: NostrSigner, - onReady: (Boolean) -> Unit, - ) = isTagged("word", word, isPrivate, signer, onReady) - - fun isTaggedUser( - idHex: String, - isPrivate: Boolean, - signer: NostrSigner, - onReady: (Boolean) -> Unit, - ) = isTagged("p", idHex, isPrivate, signer, onReady) - - companion object { - const val KIND = 30000 - const val BLOCK_LIST_D_TAG = "mute" - const val ALT = "List of people" - - fun createBlockAddress(pubKey: HexKey) = Address(KIND, pubKey, BLOCK_LIST_D_TAG) - - fun blockListFor(pubKeyHex: HexKey): String = "30000:$pubKeyHex:$BLOCK_LIST_D_TAG" - - fun createListWithTag( - name: String, - key: String, - tag: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) { - if (isPrivate) { - encryptTags(arrayOf(arrayOf(key, tag)), signer) { encryptedTags -> - create( - content = encryptedTags, - tags = arrayOf(arrayOf("d", name)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } else { - create( - content = "", - tags = arrayOf(arrayOf("d", name), arrayOf(key, tag)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - - fun createListWithUser( - name: String, - pubKeyHex: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) = createListWithTag(name, "p", pubKeyHex, isPrivate, signer, createdAt, onReady) - - fun createListWithWord( - name: String, - word: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) = createListWithTag(name, "word", word, isPrivate, signer, createdAt, onReady) - - fun addUsers( - earlierVersion: PeopleListEvent, - listPubKeyHex: List, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = - privateTags.plus( - listPubKeyHex.map { arrayOf("p", it) }, - ), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = earlierVersion.tags, - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } else { - create( - content = earlierVersion.content, - tags = - earlierVersion.tags.plus( - listPubKeyHex.map { arrayOf("p", it) }, - ), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - - fun addWord( - earlierVersion: PeopleListEvent, - word: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) = addTag(earlierVersion, "word", word, isPrivate, signer, createdAt, onReady) - - fun addUser( - earlierVersion: PeopleListEvent, - pubKeyHex: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) = addTag(earlierVersion, "p", pubKeyHex, isPrivate, signer, createdAt, onReady) - - fun addTag( - earlierVersion: PeopleListEvent, - key: String, - tag: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) { - earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> - if (!isTagged) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = privateTags.plus(element = arrayOf(key, tag)), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = earlierVersion.tags, - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } else { - create( - content = earlierVersion.content, - tags = earlierVersion.tags.plus(element = arrayOf(key, tag)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } - } - - fun removeWord( - earlierVersion: PeopleListEvent, - word: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) = removeTag(earlierVersion, "word", word, signer, createdAt, onReady) - - fun removeUser( - earlierVersion: PeopleListEvent, - pubKeyHex: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) = removeTag(earlierVersion, "p", pubKeyHex, signer, createdAt, onReady) - - fun removeTag( - earlierVersion: PeopleListEvent, - key: String, - tag: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = - privateTags - .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } - .toTypedArray(), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = - earlierVersion.tags - .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } - .toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } - - fun create( - content: String, - tags: Array>, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) { - val newTags = - if (tags.any { it.size > 1 && it[0] == "alt" }) { - tags - } else { - tags + AltTag.assemble(ALT) - } - - signer.sign(createdAt, KIND, newTags, content, onReady) - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PinListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PinListEvent.kt index d993c3fcc5..e92935e66f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PinListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PinListEvent.kt @@ -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 @@ -42,17 +42,16 @@ class PinListEvent( const val KIND = 33888 const val ALT = "Pinned Posts" - fun create( + suspend fun create( pins: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (PinListEvent) -> Unit, - ) { + ): PinListEvent { val tags = mutableListOf>() pins.forEach { tags.add(arrayOf("pin", it)) } tags.add(AltTag.assemble(ALT)) - signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady) + return signer.sign(createdAt, KIND, tags.toTypedArray(), "") } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayBuilder.kt new file mode 100644 index 0000000000..453a7b44a6 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayBuilder.kt @@ -0,0 +1,190 @@ +/** + * 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.quartz.nip51Lists + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent + +class PrivateTagArrayBuilder { + companion object { + suspend fun create( + tags: Array>, + toPrivate: Boolean, + signer: NostrSigner, + ): Pair>> = + if (toPrivate) { + val encryptedTags = + PrivateTagsInContent.encryptNip04( + privateTags = tags, + signer = signer, + ) + Pair(encryptedTags, arrayOf()) + } else { + Pair("", tags) + } + + suspend fun add( + current: PrivateTagArrayEvent, + newTag: Array, + toPrivate: Boolean, + signer: NostrSigner, + ): Pair>> = + if (toPrivate) { + val privateTags = current.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + val encryptedTags = + PrivateTagsInContent.encryptNip04( + privateTags = privateTags.plus(newTag), + signer = signer, + ) + Pair(encryptedTags, current.tags) + } else { + Pair(current.content, current.tags.plus(newTag)) + } + + suspend fun addAll( + current: PrivateTagArrayEvent, + newTag: Array>, + toPrivate: Boolean, + signer: NostrSigner, + ): Pair>> = + if (toPrivate) { + val privateTags = current.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + val encryptedTags = + PrivateTagsInContent.encryptNip04( + privateTags = privateTags.plus(newTag), + signer = signer, + ) + Pair(encryptedTags, current.tags) + } else { + Pair(current.content, current.tags.plus(newTag)) + } + + suspend fun replaceAllToPrivateNewTag( + dTag: String, + current: PrivateTagArrayEvent?, + oldTagStartsWith: Array, + newTag: Array, + signer: NostrSigner, + ): Pair>> = + if (current == null) { + createPrivate(dTag, newTag, signer) + } else { + replaceAllToPrivateNewTag(current, oldTagStartsWith, newTag, signer) + } + + suspend fun replaceAllToPublicNewTag( + dTag: String, + current: PrivateTagArrayEvent?, + oldTagStartsWith: Array, + newTag: Array, + signer: NostrSigner, + ): Pair>> = + if (current == null) { + createPublic(dTag, newTag, signer) + } else { + replaceAllToPublicNewTag(current, oldTagStartsWith, newTag, signer) + } + + suspend fun replaceAllToPrivateNewTag( + current: PrivateTagArrayEvent, + oldTagStartsWith: Array, + newTag: Array, + signer: NostrSigner, + ): Pair>> { + val privateTags = current.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + val encryptedTags = + PrivateTagsInContent.encryptNip04( + privateTags = privateTags.replaceAll(oldTagStartsWith, newTag), + signer = signer, + ) + return Pair(encryptedTags, current.tags.remove(oldTagStartsWith)) + } + + suspend fun replaceAllToPublicNewTag( + current: PrivateTagArrayEvent, + oldTagStartsWith: Array, + newTag: Array, + signer: NostrSigner, + ): Pair>> { + val privateTags = current.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + val encryptedTags = + PrivateTagsInContent.encryptNip04( + privateTags = privateTags.remove(oldTagStartsWith), + signer = signer, + ) + return Pair(encryptedTags, current.tags.remove(oldTagStartsWith).plus(newTag)) + } + + suspend fun removeAllFromPrivate( + current: PrivateTagArrayEvent, + oldTagStartsWith: Array, + signer: NostrSigner, + ): Pair>> { + val privateTags = current.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + val encryptedTags = + PrivateTagsInContent.encryptNip04( + privateTags = privateTags.remove(oldTagStartsWith), + signer = signer, + ) + return Pair(encryptedTags, current.tags) + } + + fun removeAllFromPublic( + current: PrivateTagArrayEvent, + oldTagStartsWith: Array, + signer: NostrSigner, + ): Pair>> = Pair(current.content, current.tags.remove(oldTagStartsWith)) + + suspend fun removeAll( + current: PrivateTagArrayEvent, + oldTagStartsWith: Array, + signer: NostrSigner, + ): Pair>> { + val privateTags = current.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + val encryptedTags = + PrivateTagsInContent.encryptNip04( + privateTags = privateTags.remove(oldTagStartsWith), + signer = signer, + ) + return Pair(encryptedTags, current.tags.remove(oldTagStartsWith)) + } + + suspend fun createPrivate( + dTag: String, + newTag: Array, + signer: NostrSigner, + ): Pair>> { + val encryptedTags = + PrivateTagsInContent.encryptNip04( + privateTags = arrayOf(newTag), + signer = signer, + ) + return Pair(encryptedTags, arrayOf(arrayOf("d", dTag))) + } + + fun createPublic( + dTag: String, + newTag: Array, + signer: NostrSigner, + ): Pair>> = Pair("", arrayOf(arrayOf("d", dTag), newTag)) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEvent.kt index 47ce039faa..12d70ed5c8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEvent.kt @@ -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,14 +20,14 @@ */ package com.vitorpamplona.quartz.nip51Lists -import android.util.Log import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent -import com.vitorpamplona.quartz.utils.bytesUsedInMemory -import com.vitorpamplona.quartz.utils.pointerSizeInBytes +import kotlinx.coroutines.CancellationException @Immutable abstract class PrivateTagArrayEvent( @@ -35,226 +35,28 @@ abstract class PrivateTagArrayEvent( pubKey: HexKey, createdAt: Long, kind: Int, - tags: Array>, + tags: TagArray, content: String, sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { - @Transient private var privateTagsCache: Array>? = null - - override fun countMemory(): Long = - super.countMemory() + - pointerSizeInBytes + (privateTagsCache?.sumOf { pointerSizeInBytes + it.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() } } ?: 0) - override fun isContentEncoded() = true - fun cachedPrivateTags(): Array>? = privateTagsCache + suspend fun decrypt(signer: NostrSigner): TagArray { + if (signer.pubKey != pubKey) throw SignerExceptions.UnauthorizedDecryptionException() - fun privateTags( - signer: NostrSigner, - onReady: (Array>) -> Unit, - ) { - if (content.isEmpty()) { - onReady(emptyArray()) - return - } - - privateTagsCache?.let { - onReady(it) - return - } - - try { - PrivateTagsInContent.decrypt(content, signer) { - privateTagsCache = it - privateTagsCache?.let { onReady(it) } - } - } catch (e: Throwable) { - Log.w("GeneralList", "Error parsing the JSON ${e.message}") - } + return PrivateTagsInContent.decrypt(content, signer) } - fun decryptChangeEncrypt( - signer: NostrSigner, - change: (Array>) -> Array>, - onReady: (content: String) -> Unit, - ) { - privateTags(signer) { privateTags -> - PrivateTagsInContent.encryptNip04( - privateTags = change(privateTags), - signer = signer, - ) { encryptedTags -> - onReady(encryptedTags) - } - } - } - - companion object { - fun add( - current: PrivateTagArrayEvent, - newTag: Array, - toPrivate: Boolean, - signer: NostrSigner, - onReady: (content: String, tags: Array>) -> Unit, - ) { - if (toPrivate) { - current.privateTags(signer) { privateTags -> - PrivateTagsInContent.encryptNip04( - privateTags = privateTags.plus(newTag), - signer = signer, - ) { encryptedTags -> - onReady(encryptedTags, current.tags) - } - } - } else { - onReady(current.content, current.tags.plus(newTag)) - } + suspend fun privateTags(signer: NostrSigner): TagArray? { + if (signer.pubKey != pubKey) { + return null } - fun addAll( - current: PrivateTagArrayEvent, - newTag: Array>, - toPrivate: Boolean, - signer: NostrSigner, - onReady: (content: String, tags: Array>) -> Unit, - ) { - if (toPrivate) { - current.privateTags(signer) { privateTags -> - PrivateTagsInContent.encryptNip04( - privateTags = privateTags.plus(newTag), - signer = signer, - ) { encryptedTags -> - onReady(encryptedTags, current.tags) - } - } - } else { - onReady(current.content, current.tags.plus(newTag)) - } - } - - fun replaceAllToPrivateNewTag( - dTag: String, - current: PrivateTagArrayEvent?, - oldTagStartsWith: Array, - newTag: Array, - signer: NostrSigner, - onReady: (content: String, tags: Array>) -> Unit, - ) { - if (current == null) { - createPrivate(dTag, newTag, signer, onReady) - } else { - replaceAllToPrivateNewTag(current, oldTagStartsWith, newTag, signer, onReady) - } - } - - fun replaceAllToPublicNewTag( - dTag: String, - current: PrivateTagArrayEvent?, - oldTagStartsWith: Array, - newTag: Array, - signer: NostrSigner, - onReady: (content: String, tags: Array>) -> Unit, - ) { - if (current == null) { - createPublic(dTag, newTag, signer, onReady) - } else { - replaceAllToPublicNewTag(current, oldTagStartsWith, newTag, signer, onReady) - } - } - - fun replaceAllToPrivateNewTag( - current: PrivateTagArrayEvent, - oldTagStartsWith: Array, - newTag: Array, - signer: NostrSigner, - onReady: (content: String, tags: Array>) -> Unit, - ) { - current.privateTags(signer) { privateTags -> - PrivateTagsInContent.encryptNip04( - privateTags = privateTags.replaceAll(oldTagStartsWith, newTag), - signer = signer, - ) { encryptedTags -> - onReady(encryptedTags, current.tags.remove(oldTagStartsWith)) - } - } - } - - fun replaceAllToPublicNewTag( - current: PrivateTagArrayEvent, - oldTagStartsWith: Array, - newTag: Array, - signer: NostrSigner, - onReady: (content: String, tags: Array>) -> Unit, - ) { - current.privateTags(signer) { privateTags -> - PrivateTagsInContent.encryptNip04( - privateTags = privateTags.remove(oldTagStartsWith), - signer = signer, - ) { encryptedTags -> - onReady(encryptedTags, current.tags.remove(oldTagStartsWith).plus(newTag)) - } - } - } - - fun removeAllFromPrivate( - current: PrivateTagArrayEvent, - oldTagStartsWith: Array, - signer: NostrSigner, - onReady: (content: String, tags: Array>) -> Unit, - ) { - current.privateTags(signer) { privateTags -> - PrivateTagsInContent.encryptNip04( - privateTags = privateTags.remove(oldTagStartsWith), - signer = signer, - ) { encryptedTags -> - onReady(encryptedTags, current.tags) - } - } - } - - fun removeAllFromPublic( - current: PrivateTagArrayEvent, - oldTagStartsWith: Array, - signer: NostrSigner, - onReady: (content: String, tags: Array>) -> Unit, - ) = onReady(current.content, current.tags.remove(oldTagStartsWith)) - - fun removeAll( - current: PrivateTagArrayEvent, - oldTagStartsWith: Array, - signer: NostrSigner, - onReady: (content: String, tags: Array>) -> Unit, - ) { - current.privateTags(signer) { privateTags -> - PrivateTagsInContent.encryptNip04( - privateTags = privateTags.remove(oldTagStartsWith), - signer = signer, - ) { encryptedTags -> - onReady(encryptedTags, current.tags.remove(oldTagStartsWith)) - } - } - } - - fun createPrivate( - dTag: String, - newTag: Array, - signer: NostrSigner, - onReady: (content: String, tags: Array>) -> Unit, - ) { - PrivateTagsInContent.encryptNip04( - privateTags = arrayOf(newTag), - signer = signer, - ) { encryptedTags -> - onReady(encryptedTags, arrayOf(arrayOf("d", dTag))) - } - } - - fun createPublic( - dTag: String, - newTag: Array, - signer: NostrSigner, - onReady: (content: String, tags: Array>) -> Unit, - ) { - onReady("", arrayOf(arrayOf("d", dTag), newTag)) + return try { + PrivateTagsInContent.decrypt(content, signer) + } catch (e: Exception) { + if (e is CancellationException) throw e + null } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEventCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEventCache.kt new file mode 100644 index 0000000000..e2ac34b1a7 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEventCache.kt @@ -0,0 +1,61 @@ +/** + * 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.quartz.nip51Lists + +import androidx.collection.LruCache +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.caches.DecryptCache +import kotlin.collections.plus + +class PrivateTagArrayEventCache( + signer: NostrSigner, + cacheSize: Int = 10, +) { + private val decryptionCache = + object : LruCache>(cacheSize) { + override fun create(key: T): PrivateTagArrayEventDecryptCache? = + if (key.content.isNotBlank() && key.pubKey == signer.pubKey) { + PrivateTagArrayEventDecryptCache(signer) + } else { + null + } + } + + fun remove(event: T) = decryptionCache.remove(event) + + fun cachedPrivateTags(event: T): TagArray? = decryptionCache[event]?.cached() + + suspend fun privateTags(event: T) = decryptionCache[event]?.decrypt(event) + + suspend fun mergeTagList(event: T): TagArray = event.tags + (privateTags(event) ?: emptyArray()) + + fun mergeTagListPrecached(event: T): TagArray = event.tags + (cachedPrivateTags(event) ?: emptyArray()) +} + +class PrivateTagArrayEventDecryptCache( + signer: NostrSigner, +) : DecryptCache(signer) { + override suspend fun decryptAndParse( + event: T, + signer: NostrSigner, + ): TagArray = event.decrypt(signer) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/TagArrayExt.kt index 2ccfca7928..0b03a2b19f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/TagArrayExt.kt @@ -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,8 +20,11 @@ */ package com.vitorpamplona.quartz.nip51Lists +import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.utils.startsWith +import com.vitorpamplona.quartz.utils.startsWithAny +import kotlin.collections.ArrayList inline fun TagArray.filterToArray(predicate: (Array) -> Boolean): TagArray = filterTo(ArrayList(), predicate).toTypedArray() @@ -29,6 +32,25 @@ inline fun TagArray.remove(predicate: (Array) -> Boolean): TagArray = fi fun TagArray.remove(startsWith: Array): TagArray = filterNotTo(ArrayList(this.size), { it.startsWith(startsWith) }).toTypedArray() +fun TagArray.removeParsing( + transform: (Tag) -> R, + equalsTo: R, +): TagArray = + filterNotTo( + destination = ArrayList(this.size), + predicate = { + transform(it) == equalsTo + }, + ).toTypedArray() + +fun TagArray.removeAny(startsWith: List>): TagArray = + filterNotTo( + ArrayList(this.size), + { + it.startsWithAny(startsWith) + }, + ).toTypedArray() + fun TagArray.replaceAll( startsWith: Array, newElement: Array, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/bookmarkList/BookmarkListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/bookmarkList/BookmarkListEvent.kt new file mode 100644 index 0000000000..2aa6f41dc3 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/bookmarkList/BookmarkListEvent.kt @@ -0,0 +1,212 @@ +/** + * 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.quartz.nip51Lists.bookmarkList + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +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 com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.nip51Lists.tags.NameTag +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class BookmarkListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider { + override fun eventHints() = tags.mapNotNull(EventBookmark::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(EventBookmark::parseId) + + override fun addressHints() = tags.mapNotNull(AddressBookmark::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(AddressBookmark::parseAddressId) + + fun name() = tags.firstNotNullOfOrNull(NameTag::parse) + + fun countBookmarks() = tags.count(BookmarkIdTag::isTagged) + + fun publicBookmarks(): List = tags.mapNotNull(BookmarkIdTag::parse) + + suspend fun privateBookmarks(signer: NostrSigner): List? = privateTags(signer)?.mapNotNull(BookmarkIdTag::parse) + + companion object { + const val KIND = 30001 + const val ALT = "List of bookmarks" + const val DEFAULT_D_TAG_BOOKMARKS = "bookmark" + + fun createBookmarkAddress(pubKey: HexKey) = Address(KIND, pubKey, DEFAULT_D_TAG_BOOKMARKS) + + suspend fun create( + bookmarkIdTag: BookmarkIdTag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): BookmarkListEvent = + if (isPrivate) { + create( + publicBookmarks = emptyList(), + privateBookmarks = listOf(bookmarkIdTag), + signer = signer, + createdAt = createdAt, + ) + } else { + create( + publicBookmarks = listOf(bookmarkIdTag), + privateBookmarks = emptyList(), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun add( + earlierVersion: BookmarkListEvent, + bookmarkIdTag: BookmarkIdTag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): BookmarkListEvent = + if (isPrivate) { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + tags = earlierVersion.tags, + privateTags = privateTags.plus(bookmarkIdTag.toTagArray()), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.plus(bookmarkIdTag.toTagArray()), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun remove( + earlierVersion: BookmarkListEvent, + bookmarkIdTag: BookmarkIdTag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): BookmarkListEvent = + if (isPrivate) { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + privateTags = privateTags.remove(bookmarkIdTag.toTagIdOnly()), + tags = earlierVersion.tags.remove(bookmarkIdTag.toTagIdOnly()), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = + earlierVersion.tags.remove(bookmarkIdTag.toTagIdOnly()), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun resign( + tags: TagArray, + privateTags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = resign( + content = PrivateTagsInContent.encryptNip04(privateTags, signer), + tags = tags, + signer = signer, + createdAt = createdAt, + ) + + suspend fun resign( + content: String, + tags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): BookmarkListEvent { + val newTags = + if (tags.fastAny(AltTag::match)) { + tags + } else { + tags + AltTag.assemble(ALT) + } + + return signer.sign(createdAt, KIND, newTags, content) + } + + suspend fun create( + name: String = "", + publicBookmarks: List = emptyList(), + privateBookmarks: List = emptyList(), + dTag: String = DEFAULT_D_TAG_BOOKMARKS, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): BookmarkListEvent { + val template = build(name, publicBookmarks, privateBookmarks, signer, dTag, createdAt) + return signer.sign(template) + } + + suspend fun build( + name: String = "", + publicBookmarks: List = emptyList(), + privateBookmarks: List = emptyList(), + signer: NostrSigner, + dTag: String = DEFAULT_D_TAG_BOOKMARKS, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip04(privateBookmarks.map { it.toTagArray() }.toTypedArray(), signer), + createdAt = createdAt, + ) { + dTag(dTag) + alt(ALT) + name(name) + bookmarks(publicBookmarks) + + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/bookmarkList/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/bookmarkList/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..3fc8ebe461 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/bookmarkList/TagArrayBuilderExt.kt @@ -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.quartz.nip51Lists.bookmarkList + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag +import com.vitorpamplona.quartz.nip51Lists.tags.NameTag + +fun TagArrayBuilder.name(name: String) = addUnique(NameTag.assemble(name)) + +fun TagArrayBuilder.bookmarks(bookmarks: List) = addAll(bookmarks.map { it.toTagArray() }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/bookmarkList/tags/AddressBookmark.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/bookmarkList/tags/AddressBookmark.kt new file mode 100644 index 0000000000..ede0ece456 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/bookmarkList/tags/AddressBookmark.kt @@ -0,0 +1,150 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.bytesUsedInMemory +import com.vitorpamplona.quartz.utils.ensure +import com.vitorpamplona.quartz.utils.pointerSizeInBytes + +class AddressBookmark( + val address: Address, + val relayHint: NormalizedRelayUrl? = null, +) : BookmarkIdTag { + fun countMemory(): Long = 2 * pointerSizeInBytes + address.countMemory() + (relayHint?.url?.bytesUsedInMemory() ?: 0) + + fun toTag() = Address.assemble(address.kind, address.pubKeyHex, address.dTag) + + override fun toTagArray() = assemble(address, relayHint) + + override fun toTagIdOnly() = assemble(address, null) + + companion object { + const val TAG_NAME = "a" + + @JvmStatic + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun isTagged( + tag: Array, + addressId: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == addressId + + @JvmStatic + fun isTagged( + tag: Array, + address: AddressBookmark, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == address.toTag() + + @JvmStatic + fun isIn( + tag: Array, + addressIds: Set, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in addressIds + + @JvmStatic + fun isTaggedWithKind( + tag: Array, + kind: String, + ) = tag.has(1) && tag[0] == TAG_NAME && Address.isOfKind(tag[1], kind) + + @JvmStatic + fun parse( + aTagId: String, + relay: String?, + ) = Address.parse(aTagId)?.let { + AddressBookmark(it, relay?.let { RelayUrlNormalizer.normalizeOrNull(it) }) + } + + @JvmStatic + fun parse(tag: Array): AddressBookmark? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return parse(tag[1], tag.getOrNull(2)) + } + + @JvmStatic + fun parseValidAddress(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return Address.parse(tag[1])?.toValue() + } + + @JvmStatic + fun parseAddress(tag: Array): Address? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return Address.parse(tag[1]) + } + + @JvmStatic + fun parseAddressId(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + @JvmStatic + fun parseAsHint(tag: Array): AddressHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + ensure(tag[1].contains(':')) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(relayHint != null) { return null } + + return AddressHint(tag[1], relayHint) + } + + @JvmStatic + fun assemble( + aTagId: HexKey, + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, aTagId, relay?.url) + + @JvmStatic + fun assemble( + address: Address, + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, address.toValue(), relay?.url) + + @JvmStatic + fun assemble( + kind: Int, + pubKey: String, + dTag: String, + relay: NormalizedRelayUrl?, + ) = assemble(Address.assemble(kind, pubKey, dTag), relay) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/BookId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/bookmarkList/tags/BookmarkIdTag.kt similarity index 71% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/BookId.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/bookmarkList/tags/BookmarkIdTag.kt index 812f789587..a3f344e459 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/BookId.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/bookmarkList/tags/BookmarkIdTag.kt @@ -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,22 +18,18 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip73ExternalIds +package com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags -class BookId( - val isbn: String, - val hint: String? = null, -) : ExternalId { - override fun toScope() = toScope(isbn) +import com.vitorpamplona.quartz.nip01Core.core.Tag - override fun toKind() = toKind(isbn) +sealed interface BookmarkIdTag { + fun toTagArray(): Tag - override fun hint() = hint + fun toTagIdOnly(): Tag companion object { - // "isbn:9780765382030" - fun toScope(isbn: String) = "isbn:" + isbn.lowercase().replace("-", "") + fun isTagged(tag: Array) = EventBookmark.isTagged(tag) || AddressBookmark.isTagged(tag) - fun toKind(isbn: String) = "isbn" + fun parse(tag: Array): BookmarkIdTag? = EventBookmark.parse(tag) ?: AddressBookmark.parse(tag) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/bookmarkList/tags/EventBookmark.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/bookmarkList/tags/EventBookmark.kt new file mode 100644 index 0000000000..8bfeb5a591 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/bookmarkList/tags/EventBookmark.kt @@ -0,0 +1,119 @@ +/** + * 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.quartz.nip51Lists.bookmarkList.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.bytesUsedInMemory +import com.vitorpamplona.quartz.utils.ensure +import com.vitorpamplona.quartz.utils.pointerSizeInBytes + +@Immutable +class EventBookmark( + val eventId: HexKey, + val relay: NormalizedRelayUrl? = null, + val author: HexKey? = null, +) : BookmarkIdTag { + fun countMemory(): Long = + 3 * pointerSizeInBytes + // 3 fields, 4 bytes each reference (32bit) + eventId.bytesUsedInMemory() + + (relay?.url?.bytesUsedInMemory() ?: 0) + + (author?.bytesUsedInMemory() ?: 0) + + fun toNEvent(): String = NEvent.create(eventId, author, null, relay) + + override fun toTagArray() = assemble(eventId, relay, author) + + override fun toTagIdOnly() = assemble(eventId, null, null) + + companion object { + const val TAG_NAME = "e" + + @JvmStatic + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64 + + @JvmStatic + fun isTagged( + tag: Array, + eventId: HexKey, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == eventId + + @JvmStatic + fun parse(tag: Array): EventBookmark? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + + return EventBookmark(tag[1], pickRelayHint(tag), pickAuthor(tag)) + } + + @JvmStatic + fun parseId(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + return tag[1] + } + + @JvmStatic + private fun pickRelayHint(tag: Array): NormalizedRelayUrl? { + if (tag.has(2) && tag[2].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[2])) return RelayUrlNormalizer.normalizeOrNull(tag[2]) + if (tag.has(3) && tag[3].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[3])) return RelayUrlNormalizer.normalizeOrNull(tag[3]) + if (tag.has(4) && tag[4].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[4])) return RelayUrlNormalizer.normalizeOrNull(tag[4]) + return null + } + + @JvmStatic + private fun pickAuthor(tag: Array): HexKey? { + if (tag.has(2) && tag[2].length == 64) return tag[2] + if (tag.has(3) && tag[3].length == 64) return tag[3] + if (tag.has(4) && tag[4].length == 64) return tag[4] + return null + } + + @JvmStatic + fun parseAsHint(tag: Array): EventIdHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val hint = pickRelayHint(tag) + + ensure(hint != null) { return null } + + return EventIdHint(tag[1], hint) + } + + @JvmStatic + fun assemble( + eventId: HexKey, + relay: NormalizedRelayUrl?, + author: HexKey?, + ) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, author) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/encryption/NostrSignerExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/encryption/NostrSignerExt.kt new file mode 100644 index 0000000000..7525a1a1d9 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/encryption/NostrSignerExt.kt @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.encryption + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync + +suspend fun NostrSigner.signNip51List( + createdAt: Long, + kind: Int, + tags: Array>, + privateTags: Array>, +) = sign( + createdAt = createdAt, + kind = kind, + tags = tags, + content = PrivateTagsInContent.encryptNip44(privateTags, this), +) + +fun NostrSignerSync.signNip51List( + createdAt: Long, + kind: Int, + tags: Array>, + privateTags: Array>, +) = sign( + createdAt = createdAt, + kind = kind, + tags = tags, + content = PrivateTagsInContent.encryptNip44(privateTags, this), +) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/encryption/PrivateTagsInContent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/encryption/PrivateTagsInContent.kt index 4e91e5e7a0..fec602548c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/encryption/PrivateTagsInContent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/encryption/PrivateTagsInContent.kt @@ -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,44 +20,76 @@ */ package com.vitorpamplona.quartz.nip51Lists.encryption +import android.util.Log +import com.fasterxml.jackson.core.JsonParseException import com.fasterxml.jackson.module.kotlin.readValue -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync class PrivateTagsInContent { companion object { - fun decode(content: String) = EventMapper.mapper.readValue>>(content) + fun decode(content: String) = JsonMapper.mapper.readValue>>(content) - fun encode(privateTags: Array>) = EventMapper.mapper.writeValueAsString(privateTags) + fun encode(privateTags: Array>): String = JsonMapper.mapper.writeValueAsString(privateTags) - fun decrypt( + suspend fun decrypt( content: String, signer: NostrSigner, - onReady: (Array>) -> Unit, - ) { - signer.decrypt(content, signer.pubKey) { - onReady(decode(it)) + ): TagArray { + if (content.isBlank()) return emptyArray() + val json = signer.decrypt(content, signer.pubKey) + return try { + decode(json) + } catch (e: JsonParseException) { + Log.w("DraftEvent", "Unable to parse inner event of a draft: $json") + throw e } } + suspend fun encryptNip04( + privateTags: Array>? = null, + signer: NostrSigner, + ): String = + signer.nip04Encrypt( + if (privateTags.isNullOrEmpty()) "" else encode(privateTags), + signer.pubKey, + ) + + suspend fun encryptNip44( + privateTags: Array>? = null, + signer: NostrSigner, + ): String = + signer.nip44Encrypt( + if (privateTags.isNullOrEmpty()) "" else encode(privateTags), + signer.pubKey, + ) + + suspend fun decrypt( + content: String, + signer: NostrSignerSync, + ): TagArray { + val json = signer.decrypt(content, signer.pubKey) + return decode(json) + } + fun encryptNip04( privateTags: Array>? = null, - signer: NostrSigner, - onReady: (String) -> Unit, - ) = signer.nip04Encrypt( - if (privateTags.isNullOrEmpty()) "" else encode(privateTags), - signer.pubKey, - onReady, - ) + signer: NostrSignerSync, + ): String = + signer.nip04Encrypt( + if (privateTags.isNullOrEmpty()) "" else encode(privateTags), + signer.pubKey, + ) fun encryptNip44( privateTags: Array>? = null, - signer: NostrSigner, - onReady: (String) -> Unit, - ) = signer.nip44Encrypt( - if (privateTags.isNullOrEmpty()) "" else encode(privateTags), - signer.pubKey, - onReady, - ) + signer: NostrSignerSync, + ): String = + signer.nip44Encrypt( + if (privateTags.isNullOrEmpty()) "" else encode(privateTags), + signer.pubKey, + ) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/followList/FollowListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/followList/FollowListEvent.kt new file mode 100644 index 0000000000..fcecb05dbc --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/followList/FollowListEvent.kt @@ -0,0 +1,167 @@ +/** + * 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.quartz.nip51Lists.followList + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag +import com.vitorpamplona.quartz.nip51Lists.tags.ImageTag +import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag +import com.vitorpamplona.quartz.utils.TimeUtils +import java.util.UUID +import kotlin.collections.plus + +@Immutable +class FollowListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + PubKeyHintProvider { + override fun pubKeyHints() = tags.mapNotNull(UserTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(UserTag::parseKey) + + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) + + fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse) + + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) + + fun follows() = tags.follows() + + fun followIds() = tags.followIds() + + fun followIdSet() = tags.followIdSet() + + companion object { + const val KIND = 39089 + const val ALT = "List of people to follow" + + suspend fun create( + name: String, + person: UserTag, + signer: NostrSigner, + dTag: String = UUID.randomUUID().toString(), + createdAt: Long = TimeUtils.now(), + ): FollowListEvent = + create( + name = name, + people = listOf(person), + signer = signer, + dTag = dTag, + createdAt = createdAt, + ) + + suspend fun addUsers( + earlierVersion: FollowListEvent, + people: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FollowListEvent = + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.plus(people.map { it.toTagArray() }), + signer = signer, + createdAt = createdAt, + ) + + suspend fun add( + earlierVersion: FollowListEvent, + person: UserTag, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = addUsers(earlierVersion, listOf(person), signer, createdAt) + + suspend fun remove( + earlierVersion: FollowListEvent, + person: UserTag, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FollowListEvent = + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.remove(person.toTagIdOnly()), + signer = signer, + createdAt = createdAt, + ) + + suspend fun resign( + content: String, + tags: Array>, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FollowListEvent { + val newTags = + if (tags.fastAny(AltTag::match)) { + tags + } else { + tags + AltTag.assemble(ALT) + } + + return signer.sign(createdAt, KIND, newTags, content) + } + + suspend fun create( + name: String, + people: List = emptyList(), + signer: NostrSigner, + dTag: String = UUID.randomUUID().toString(), + createdAt: Long = TimeUtils.now(), + ): FollowListEvent { + val template = build(name, people, dTag, createdAt) + return signer.sign(template) + } + + fun build( + name: String, + people: List = emptyList(), + dTag: String = UUID.randomUUID().toString(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = "", + createdAt = createdAt, + ) { + dTag(dTag) + alt(ALT) + name(name) + people(people) + + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/followList/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/followList/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..511395f544 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/followList/TagArrayBuilderExt.kt @@ -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.quartz.nip51Lists.followList + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip51Lists.tags.NameTag + +fun TagArrayBuilder.name(name: String) = addUnique(NameTag.assemble(name)) + +fun TagArrayBuilder.people(peoples: List) = addAll(peoples.map { it.toTagArray() }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/followList/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/followList/TagArrayExt.kt new file mode 100644 index 0000000000..191b6e869f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/followList/TagArrayExt.kt @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.followList + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag + +fun TagArray.follows() = mapNotNull(UserTag::parse) + +fun TagArray.followIds() = mapNotNull(UserTag::parseKey) + +fun TagArray.followIdSet() = mapNotNullTo(mutableSetOf(), UserTag::parseKey) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/geohashList/GeohashListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/geohashList/GeohashListEvent.kt new file mode 100644 index 0000000000..622e2767e3 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/geohashList/GeohashListEvent.kt @@ -0,0 +1,220 @@ +/** + * 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.quartz.nip51Lists.geohashList + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.nip51Lists.removeAny +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class GeohashListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun publicGeohashes() = tags.geohashList() + + suspend fun decryptPrivateGeohashes(signer: NostrSigner) = privateTags(signer)?.geohashList() + + suspend fun decryptGeohashes(signer: NostrSigner): List = publicGeohashes() + (decryptPrivateGeohashes(signer) ?: emptyList()) + + companion object { + const val KIND = 10081 + const val ALT = "Geohash List" + const val FIXED_D_TAG = "" + + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) + + suspend fun create( + geohash: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = create( + geohashes = listOf(geohash), + isPrivate = isPrivate, + signer = signer, + createdAt = createdAt, + ) + + suspend fun create( + geohashes: List, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): GeohashListEvent = + if (isPrivate) { + create( + publicGeohashes = emptyList(), + privateGeohashes = geohashes, + signer = signer, + createdAt = createdAt, + ) + } else { + create( + publicGeohashes = geohashes, + privateGeohashes = emptyList(), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun add( + earlierVersion: GeohashListEvent, + geohash: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = add( + earlierVersion = earlierVersion, + geohashes = listOf(geohash), + isPrivate = isPrivate, + signer = signer, + createdAt = createdAt, + ) + + suspend fun add( + earlierVersion: GeohashListEvent, + geohashes: List, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): GeohashListEvent { + val geohashTags = geohashes.map { GeoHashTag.assembleSingle(it) } + return if (isPrivate) { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + tags = earlierVersion.tags, + privateTags = privateTags.removeAny(geohashTags) + geohashTags, + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.removeAny(geohashTags) + geohashTags, + signer = signer, + createdAt = createdAt, + ) + } + } + + suspend fun remove( + earlierVersion: GeohashListEvent, + geohash: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): GeohashListEvent { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + return resign( + privateTags = privateTags.remove(GeoHashTag.assembleSingle(geohash)), + tags = earlierVersion.tags.remove(GeoHashTag.assembleSingle(geohash)), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun resign( + tags: TagArray, + privateTags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = resign( + content = PrivateTagsInContent.encryptNip04(privateTags, signer), + tags = tags, + signer = signer, + createdAt = createdAt, + ) + + suspend fun resign( + content: String, + tags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): GeohashListEvent { + val newTags = + if (tags.fastAny(AltTag::match)) { + tags + } else { + tags + AltTag.assemble(ALT) + } + + return signer.sign(createdAt, KIND, newTags, content) + } + + suspend fun create( + publicGeohashes: List = emptyList(), + privateGeohashes: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): GeohashListEvent { + val template = build(publicGeohashes, privateGeohashes, signer, createdAt) + return signer.sign(template) + } + + fun create( + publicGeohashes: List = emptyList(), + privateGeohashes: List = emptyList(), + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): GeohashListEvent { + val privateTagArray = publicGeohashes.map { GeoHashTag.assembleSingle(it) }.toTypedArray() + val publicTagArray = privateGeohashes.map { GeoHashTag.assembleSingle(it) }.toTypedArray() + AltTag.assemble(ALT) + return signer.signNip51List(createdAt, KIND, publicTagArray, privateTagArray) + } + + suspend fun build( + publicGeohashes: List = emptyList(), + privateGeohashes: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip04(privateGeohashes.map { GeoHashTag.assembleSingle(it) }.toTypedArray(), signer), + createdAt = createdAt, + ) { + alt(ALT) + geohashes(publicGeohashes) + + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/geohashList/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/geohashList/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..b10d923486 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/geohashList/TagArrayBuilderExt.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.geohashList + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag + +fun TagArrayBuilder.followGeohash(geohash: String) = add(GeoHashTag.assembleSingle(geohash)) + +fun TagArrayBuilder.geohashes(geohash: List) = addAll(geohash.map { GeoHashTag.assembleSingle(it) }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/geohashList/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/geohashList/TagArrayExt.kt new file mode 100644 index 0000000000..efaeb4292e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/geohashList/TagArrayExt.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.geohashList + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag + +fun TagArray.geohashList() = mapNotNull(GeoHashTag::parse) + +fun TagArray.geohashSet() = mapNotNullTo(mutableSetOf(), GeoHashTag::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/hashtagList/HashtagListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/hashtagList/HashtagListEvent.kt new file mode 100644 index 0000000000..1fc7071206 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/hashtagList/HashtagListEvent.kt @@ -0,0 +1,217 @@ +/** + * 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.quartz.nip51Lists.hashtagList + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.nip51Lists.removeAny +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.map + +@Immutable +class HashtagListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun publicHashtags() = tags.mapNotNull(HashtagTag::parse) + + companion object { + const val KIND = 10015 + const val ALT = "Hashtag List" + const val FIXED_D_TAG = "" + + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) + + suspend fun create( + hashtag: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = create( + hashtags = listOf(hashtag), + isPrivate = isPrivate, + signer = signer, + createdAt = createdAt, + ) + + suspend fun create( + hashtags: List, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): HashtagListEvent = + if (isPrivate) { + create( + publicHashtags = emptyList(), + privateHashtags = hashtags, + signer = signer, + createdAt = createdAt, + ) + } else { + create( + publicHashtags = hashtags, + privateHashtags = emptyList(), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun add( + earlierVersion: HashtagListEvent, + hashtag: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = add( + earlierVersion = earlierVersion, + hashtags = listOf(hashtag), + isPrivate = isPrivate, + signer = signer, + createdAt = createdAt, + ) + + suspend fun add( + earlierVersion: HashtagListEvent, + hashtags: List, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): HashtagListEvent { + val hashtags = hashtags.map { HashtagTag.assemble(it) } + return if (isPrivate) { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + tags = earlierVersion.tags, + privateTags = privateTags.removeAny(hashtags) + hashtags, + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.removeAny(hashtags) + hashtags, + signer = signer, + createdAt = createdAt, + ) + } + } + + suspend fun remove( + earlierVersion: HashtagListEvent, + hashtag: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): HashtagListEvent { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + return resign( + privateTags = privateTags.remove(HashtagTag.assemble(hashtag)), + tags = earlierVersion.tags.remove(HashtagTag.assemble(hashtag)), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun resign( + tags: TagArray, + privateTags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = resign( + content = PrivateTagsInContent.encryptNip04(privateTags, signer), + tags = tags, + signer = signer, + createdAt = createdAt, + ) + + suspend fun resign( + content: String, + tags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): HashtagListEvent { + val newTags = + if (tags.fastAny(AltTag::match)) { + tags + } else { + tags + AltTag.assemble(ALT) + } + + return signer.sign(createdAt, KIND, newTags, content) + } + + suspend fun create( + publicHashtags: List = emptyList(), + privateHashtags: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): HashtagListEvent { + val template = build(publicHashtags, privateHashtags, signer, createdAt) + return signer.sign(template) + } + + fun create( + publicHashtags: List = emptyList(), + privateHashtags: List = emptyList(), + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): HashtagListEvent { + val privateTagArray = publicHashtags.map { HashtagTag.assemble(it) }.toTypedArray() + val publicTagArray = privateHashtags.map { HashtagTag.assemble(it) }.toTypedArray() + AltTag.assemble(ALT) + return signer.signNip51List(createdAt, KIND, publicTagArray, privateTagArray) + } + + suspend fun build( + publicHashtags: List = emptyList(), + privateHashtags: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip04(privateHashtags.map { HashtagTag.assemble(it) }.toTypedArray(), signer), + createdAt = createdAt, + ) { + alt(ALT) + hashtags(publicHashtags) + + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/hashtagList/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/hashtagList/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..e6168ce2fc --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/hashtagList/TagArrayBuilderExt.kt @@ -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.quartz.nip51Lists.hashtagList + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag + +fun TagArrayBuilder.followHashTag(hashtag: String) = add(HashtagTag.assemble(hashtag)) + +fun TagArrayBuilder.hashtags(hashtags: List) = addAll(hashtags.map { HashtagTag.assemble(it) }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/hashtagList/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/hashtagList/TagArrayExt.kt new file mode 100644 index 0000000000..7236e78713 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/hashtagList/TagArrayExt.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.hashtagList + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag + +fun TagArray.hashtagList() = mapNotNull(HashtagTag::parse) + +fun TagArray.hashtagSet() = mapNotNullTo(mutableSetOf(), HashtagTag::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/MuteListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/MuteListEvent.kt new file mode 100644 index 0000000000..70055d4d93 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/MuteListEvent.kt @@ -0,0 +1,191 @@ +/** + * 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.quartz.nip51Lists.muteList + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.plus + +@Immutable +class MuteListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + PubKeyHintProvider { + override fun pubKeyHints() = tags.mapNotNull(UserTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(UserTag::parseKey) + + fun countMutes() = tags.count(MuteTag::isTagged) + + fun publicMutes(): List = tags.mapNotNull(MuteTag::parse) + + suspend fun privateMutes(signer: NostrSigner): List? = privateTags(signer)?.mapNotNull(MuteTag::parse) + + override fun dTag() = FIXED_D_TAG + + companion object { + const val KIND = 10000 + const val FIXED_D_TAG = "" + const val ALT = "Mute List" + + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) + + fun blockListFor(pubKeyHex: HexKey): String = "10000:$pubKeyHex:" + + suspend fun create( + mute: MuteTag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): MuteListEvent = + if (isPrivate) { + create( + publicMutes = emptyList(), + privateMutes = listOf(mute), + signer = signer, + createdAt = createdAt, + ) + } else { + create( + publicMutes = listOf(mute), + privateMutes = emptyList(), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun add( + earlierVersion: MuteListEvent, + mute: MuteTag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): MuteListEvent = + if (isPrivate) { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + publicTags = earlierVersion.tags, + privateTags = privateTags.plus(mute.toTagArray()), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.plus(mute.toTagArray()), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun remove( + earlierVersion: MuteListEvent, + mute: MuteTag, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): MuteListEvent { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + + return resign( + privateTags = privateTags.remove(mute.toTagIdOnly()), + publicTags = earlierVersion.tags.remove(mute.toTagIdOnly()), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun resign( + publicTags: TagArray, + privateTags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = resign( + content = PrivateTagsInContent.encryptNip04(privateTags, signer), + tags = publicTags, + signer = signer, + createdAt = createdAt, + ) + + suspend fun resign( + content: String, + tags: Array>, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): MuteListEvent { + val newTags = + if (tags.fastAny(AltTag::match)) { + tags + } else { + tags + AltTag.assemble(ALT) + } + + return signer.sign(createdAt, KIND, newTags, content) + } + + suspend fun create( + publicMutes: List = emptyList(), + privateMutes: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): MuteListEvent { + val template = build(publicMutes, privateMutes, signer, createdAt) + return signer.sign(template) + } + + suspend fun build( + publicMutes: List = emptyList(), + privateMutes: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip04(privateMutes.map { it.toTagArray() }.toTypedArray(), signer), + createdAt = createdAt, + ) { + alt(ALT) + mutes(publicMutes) + + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..4d0fdaa2fb --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayBuilderExt.kt @@ -0,0 +1,26 @@ +/** + * 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.quartz.nip51Lists.muteList + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag + +fun TagArrayBuilder.mutes(mutes: List) = addAll(mutes.map { it.toTagArray() }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayExt.kt new file mode 100644 index 0000000000..1b918a68ef --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayExt.kt @@ -0,0 +1,38 @@ +/** + * 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.quartz.nip51Lists.muteList + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag + +fun TagArray.mutedUsersAndWords() = mapNotNull(MuteTag::parse) + +fun TagArray.mutedUsers() = mapNotNull(UserTag::parse) + +fun TagArray.mutedUserIds() = mapNotNull(UserTag::parseKey) + +fun TagArray.mutedUserIdSet() = mapNotNullTo(mutableSetOf(), UserTag::parseKey) + +fun TagArray.mutedWords() = mapNotNull(WordTag::parse) + +fun TagArray.mutedWordSet() = mapNotNullTo(mutableSetOf(), WordTag::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/GeohashId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/tags/MuteTag.kt similarity index 73% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/GeohashId.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/tags/MuteTag.kt index 94c3d1ee5b..a6a65786c9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/GeohashId.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/tags/MuteTag.kt @@ -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,21 +18,18 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip73ExternalIds +package com.vitorpamplona.quartz.nip51Lists.muteList.tags -class GeohashId( - val geohash: String, - val hint: String? = null, -) : ExternalId { - override fun toScope() = toScope(geohash) +import com.vitorpamplona.quartz.nip01Core.core.Tag - override fun toKind() = toKind(geohash) +sealed interface MuteTag { + fun toTagArray(): Tag - override fun hint() = hint + fun toTagIdOnly(): Tag companion object { - fun toScope(geohash: String) = "geo:" + geohash.lowercase() + fun isTagged(tag: Array) = WordTag.isTagged(tag) || UserTag.isTagged(tag) - fun toKind(geohash: String) = "geo" + fun parse(tag: Array): MuteTag? = WordTag.parse(tag) ?: UserTag.parse(tag) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/tags/UserTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/tags/UserTag.kt new file mode 100644 index 0000000000..2558e6d542 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/tags/UserTag.kt @@ -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.quartz.nip51Lists.muteList.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.bytesUsedInMemory +import com.vitorpamplona.quartz.utils.ensure +import com.vitorpamplona.quartz.utils.pointerSizeInBytes + +class UserTag( + val pubKey: HexKey, + val relayHint: NormalizedRelayUrl? = null, +) : MuteTag { + fun countMemory(): Long = + 2 * pointerSizeInBytes + // 2 fields, 4 bytes each reference (32bit) + pubKey.bytesUsedInMemory() + + (relayHint?.url?.bytesUsedInMemory() ?: 0) + + fun toNProfile(): String = NProfile.create(pubKey, relayHint?.let { listOf(it) } ?: emptyList()) + + fun toNPub(): String = pubKey.hexToByteArray().toNpub() + + override fun toTagArray() = assemble(pubKey, relayHint) + + override fun toTagIdOnly() = assemble(pubKey, null) + + companion object { + const val TAG_NAME = "p" + + fun isTagged(tag: Array): Boolean = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64 + + fun isTagged( + tag: Array, + key: HexKey, + ): Boolean = tag.has(1) && tag[0] == TAG_NAME && tag[1] == key + + @JvmStatic + fun parse(tag: Tag): UserTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return UserTag(tag[1], hint) + } + + @JvmStatic + fun parseKey(tag: Array): HexKey? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + return tag[1] + } + + @JvmStatic + fun parseAsHint(tag: Array): PubKeyHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val hint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + + ensure(hint != null) { return null } + + return PubKeyHint(tag[1], hint) + } + + @JvmStatic + fun assemble( + pubkey: HexKey, + relayHint: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint?.url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/tags/WordTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/tags/WordTag.kt new file mode 100644 index 0000000000..1204b5d422 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/muteList/tags/WordTag.kt @@ -0,0 +1,74 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.muteList.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.bytesUsedInMemory +import com.vitorpamplona.quartz.utils.ensure +import com.vitorpamplona.quartz.utils.pointerSizeInBytes + +@Immutable +class WordTag( + val word: String, +) : MuteTag { + fun countMemory(): Long = + 1 * pointerSizeInBytes + // 1 fields, 4 bytes each reference (32bit) + word.bytesUsedInMemory() + + override fun toTagArray() = assemble(word) + + override fun toTagIdOnly() = assemble(word) + + companion object { + const val TAG_NAME = "word" + + @JvmStatic + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun isTagged( + tag: Array, + word: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == word + + @JvmStatic + fun parse(tag: Array): WordTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + return WordTag(tag[1]) + } + + @JvmStatic + fun parseId(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + @JvmStatic + fun assemble(word: String) = arrayOfNotNull(TAG_NAME, word) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/peopleList/PeopleListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/peopleList/PeopleListEvent.kt new file mode 100644 index 0000000000..a0c69898a2 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/peopleList/PeopleListEvent.kt @@ -0,0 +1,218 @@ +/** + * 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.quartz.nip51Lists.peopleList + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag +import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag +import com.vitorpamplona.quartz.nip51Lists.tags.NameTag +import com.vitorpamplona.quartz.utils.TimeUtils +import java.util.UUID +import kotlin.collections.map +import kotlin.collections.plus + +@Immutable +class PeopleListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + PubKeyHintProvider { + override fun pubKeyHints() = tags.mapNotNull(UserTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(UserTag::parseKey) + + fun name() = tags.firstNotNullOfOrNull(NameTag::parse) + + fun nameOrTitle() = name() ?: title() + + @Deprecated("NIP-51 has deprecated Title. Use name instead", ReplaceWith("name()")) + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) + + fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse) + + fun users() = tags.users() + + fun countMutes() = tags.count(MuteTag::isTagged) + + fun publicPeople(): List = tags.mapNotNull(MuteTag::parse) + + suspend fun privatePeople(signer: NostrSigner): List? = privateTags(signer)?.mapNotNull(MuteTag::parse) + + companion object { + const val KIND = 30000 + const val BLOCK_LIST_D_TAG = "mute" + const val ALT = "List of people" + + fun createBlockAddress(pubKey: HexKey) = Address(KIND, pubKey, BLOCK_LIST_D_TAG) + + fun blockListFor(pubKeyHex: HexKey): String = "30000:$pubKeyHex:$BLOCK_LIST_D_TAG" + + suspend fun create( + name: String, + person: UserTag, + isPrivate: Boolean, + signer: NostrSigner, + dTag: String = UUID.randomUUID().toString(), + createdAt: Long = TimeUtils.now(), + ): PeopleListEvent = + if (isPrivate) { + create( + name = name, + publicPeople = emptyList(), + privatePeople = listOf(person), + signer = signer, + dTag = dTag, + createdAt = createdAt, + ) + } else { + create( + name = name, + publicPeople = listOf(person), + privatePeople = emptyList(), + signer = signer, + dTag = dTag, + createdAt = createdAt, + ) + } + + suspend fun add( + earlierVersion: PeopleListEvent, + person: UserTag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): PeopleListEvent = + if (isPrivate) { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + publicTags = earlierVersion.tags, + privateTags = privateTags.plus(person.toTagArray()), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.plus(person.toTagArray()), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun remove( + earlierVersion: PeopleListEvent, + person: MuteTag, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): PeopleListEvent { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + + return resign( + privateTags = privateTags.remove(person.toTagIdOnly()), + publicTags = earlierVersion.tags.remove(person.toTagIdOnly()), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun resign( + publicTags: TagArray, + privateTags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = resign( + content = PrivateTagsInContent.encryptNip04(privateTags, signer), + tags = publicTags, + signer = signer, + createdAt = createdAt, + ) + + suspend fun resign( + content: String, + tags: Array>, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): PeopleListEvent { + val newTags = + if (tags.fastAny(AltTag::match)) { + tags + } else { + tags + AltTag.assemble(ALT) + } + + return signer.sign(createdAt, KIND, newTags, content) + } + + suspend fun create( + name: String, + publicPeople: List = emptyList(), + privatePeople: List = emptyList(), + signer: NostrSigner, + dTag: String = UUID.randomUUID().toString(), + createdAt: Long = TimeUtils.now(), + ): PeopleListEvent { + val template = build(name, publicPeople, privatePeople, signer, dTag, createdAt) + return signer.sign(template) + } + + suspend fun build( + name: String, + publicPeople: List = emptyList(), + privatePeople: List = emptyList(), + signer: NostrSigner, + dTag: String = UUID.randomUUID().toString(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip04(privatePeople.map { it.toTagArray() }.toTypedArray(), signer), + createdAt = createdAt, + ) { + dTag(dTag) + alt(ALT) + name(name) + peoples(publicPeople) + + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/peopleList/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/peopleList/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..2c9053ab2e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/peopleList/TagArrayBuilderExt.kt @@ -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.quartz.nip51Lists.peopleList + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip51Lists.tags.NameTag + +fun TagArrayBuilder.name(name: String) = addUnique(NameTag.assemble(name)) + +fun TagArrayBuilder.peoples(peoples: List) = addAll(peoples.map { it.toTagArray() }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/peopleList/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/peopleList/TagArrayExt.kt new file mode 100644 index 0000000000..67c4568165 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/peopleList/TagArrayExt.kt @@ -0,0 +1,38 @@ +/** + * 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.quartz.nip51Lists.peopleList + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag + +fun TagArray.usersAndWords() = mapNotNull(MuteTag::parse) + +fun TagArray.users() = mapNotNull(UserTag::parse) + +fun TagArray.userIds() = mapNotNull(UserTag::parseKey) + +fun TagArray.userIdSet() = mapNotNullTo(mutableSetOf(), UserTag::parseKey) + +fun TagArray.words() = mapNotNull(WordTag::parse) + +fun TagArray.wordSet() = mapNotNullTo(mutableSetOf(), WordTag::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/BlockedRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/BlockedRelayListEvent.kt new file mode 100644 index 0000000000..3798037bde --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/BlockedRelayListEvent.kt @@ -0,0 +1,120 @@ +/** + * 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.quartz.nip51Lists.relayLists + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.blockedRelays +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relays +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class BlockedRelayListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun publicRelays() = tags.relays() + + suspend fun privateRelays(signer: NostrSigner) = privateTags(signer)?.relays() + + suspend fun relays(signer: NostrSigner): List = publicRelays() + (privateRelays(signer) ?: emptyList()) + + companion object { + const val KIND = 10006 + const val ALT = "Blocked relays from this author" + val ALT_TAG = arrayOf(AltTag.Companion.assemble(ALT)) + + fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, "") + + fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, "", null) + + fun createAddressTag(pubKey: HexKey): String = Address.Companion.assemble(KIND, pubKey, "") + + suspend fun updateRelayList( + earlierVersion: BlockedRelayListEvent, + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): BlockedRelayListEvent { + val newRelayList = relays.map { RelayTag.assemble(it) } + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + + val publicTags = earlierVersion.tags.remove(RelayTag::match) + val newPrivateTags = privateTags.remove(RelayTag::notMatch).plus(newRelayList) + + return signer.signNip51List(createdAt, KIND, publicTags, newPrivateTags) + } + + suspend fun create( + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): BlockedRelayListEvent { + val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() + return signer.signNip51List(createdAt, KIND, ALT_TAG, privateTagArray) + } + + fun create( + relays: List, + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): BlockedRelayListEvent { + val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() + return signer.signNip51List(createdAt, KIND, ALT_TAG, privateTagArray) + } + + suspend fun build( + publicRelays: List = emptyList(), + privateRelays: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip04(privateRelays.map { RelayTag.assemble(it) }.toTypedArray(), signer), + createdAt = createdAt, + ) { + alt(ALT) + blockedRelays(publicRelays) + + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/BroadcastRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/BroadcastRelayListEvent.kt new file mode 100644 index 0000000000..8ec9756107 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/BroadcastRelayListEvent.kt @@ -0,0 +1,120 @@ +/** + * 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.quartz.nip51Lists.relayLists + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.broadcastRelays +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relays +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.plus + +@Immutable +class BroadcastRelayListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun publicRelays() = tags.relays() + + suspend fun decryptPrivateRelays(signer: NostrSigner) = privateTags(signer)?.relays() + + suspend fun decryptRelays(signer: NostrSigner): List = publicRelays() + (decryptPrivateRelays(signer) ?: emptyList()) + + companion object { + const val KIND = 10088 + const val ALT = "Broadcast relays from this author" + val TAGS = arrayOf(AltTag.assemble(ALT)) + + fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, "") + + fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, "", null) + + fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, "") + + suspend fun updateRelayList( + earlierVersion: BroadcastRelayListEvent, + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): BroadcastRelayListEvent { + val newRelayList = relays.map { RelayTag.assemble(it) } + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + val publicTags = earlierVersion.tags.remove(RelayTag::match) + val newPrivateTags = privateTags.remove(RelayTag::notMatch).plus(newRelayList) + + return signer.signNip51List(createdAt, KIND, publicTags, newPrivateTags) + } + + suspend fun create( + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): BroadcastRelayListEvent { + val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() + return signer.signNip51List(createdAt, KIND, TAGS, privateTagArray) + } + + suspend fun create( + relays: List, + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): BroadcastRelayListEvent { + val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() + return signer.signNip51List(createdAt, KIND, TAGS, privateTagArray) + } + + suspend fun build( + publicRelays: List = emptyList(), + privateRelays: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip44(privateRelays.map { RelayTag.assemble(it) }.toTypedArray(), signer), + createdAt = createdAt, + ) { + alt(ALT) + broadcastRelays(publicRelays) + + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/IndexerRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/IndexerRelayListEvent.kt new file mode 100644 index 0000000000..05b2327b67 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/IndexerRelayListEvent.kt @@ -0,0 +1,121 @@ +/** + * 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.quartz.nip51Lists.relayLists + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.indexerRelays +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relays +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.plus + +@Immutable +class IndexerRelayListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun publicRelays() = tags.relays() + + suspend fun decryptPrivateRelays(signer: NostrSigner) = privateTags(signer)?.relays() + + suspend fun decryptRelays(signer: NostrSigner): List = publicRelays() + (decryptPrivateRelays(signer) ?: emptyList()) + + companion object { + const val KIND = 10086 + const val ALT = "Indexer relays from this author" + val TAGS = arrayOf(AltTag.assemble(ALT)) + + fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, "") + + fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, "", null) + + fun createAddressTag(pubKey: HexKey): String = Address.Companion.assemble(KIND, pubKey, "") + + suspend fun updateRelayList( + earlierVersion: IndexerRelayListEvent, + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): IndexerRelayListEvent { + val newRelayList = relays.map { RelayTag.assemble(it) } + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + + val publicTags = earlierVersion.tags.remove(RelayTag::match) + val newPrivateTags = privateTags.remove(RelayTag::notMatch).plus(newRelayList) + + return signer.signNip51List(createdAt, KIND, publicTags, newPrivateTags) + } + + suspend fun create( + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): IndexerRelayListEvent { + val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() + return signer.signNip51List(createdAt, KIND, TAGS, privateTagArray) + } + + suspend fun create( + relays: List, + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): IndexerRelayListEvent { + val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() + return signer.signNip51List(createdAt, KIND, TAGS, privateTagArray) + } + + suspend fun build( + publicRelays: List = emptyList(), + privateRelays: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip44(privateRelays.map { RelayTag.assemble(it) }.toTypedArray(), signer), + createdAt = createdAt, + ) { + alt(ALT) + indexerRelays(publicRelays) + + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/ProxyRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/ProxyRelayListEvent.kt new file mode 100644 index 0000000000..7f48f3fc45 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/ProxyRelayListEvent.kt @@ -0,0 +1,121 @@ +/** + * 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.quartz.nip51Lists.relayLists + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.proxyRelays +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relays +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.plus + +@Immutable +class ProxyRelayListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun publicRelays() = tags.relays() + + suspend fun decryptPrivateRelays(signer: NostrSigner) = privateTags(signer)?.relays() + + suspend fun decryptRelays(signer: NostrSigner): List = publicRelays() + (decryptPrivateRelays(signer) ?: emptyList()) + + companion object { + const val KIND = 10087 + const val ALT = "Proxy relays from this author" + val TAGS = arrayOf(AltTag.assemble(ALT)) + + fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, "") + + fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, "", null) + + fun createAddressTag(pubKey: HexKey): String = Address.Companion.assemble(KIND, pubKey, "") + + suspend fun updateRelayList( + earlierVersion: ProxyRelayListEvent, + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): ProxyRelayListEvent { + val newRelayList = relays.map { RelayTag.assemble(it) } + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + + val publicTags = earlierVersion.tags.remove(RelayTag::match) + val newPrivateTags = privateTags.remove(RelayTag::notMatch).plus(newRelayList) + + return signer.signNip51List(createdAt, KIND, publicTags, newPrivateTags) + } + + suspend fun create( + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): ProxyRelayListEvent { + val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() + return signer.signNip51List(createdAt, KIND, TAGS, privateTagArray) + } + + suspend fun create( + relays: List, + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): ProxyRelayListEvent { + val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() + return signer.signNip51List(createdAt, KIND, TAGS, privateTagArray) + } + + suspend fun build( + publicRelays: List = emptyList(), + privateRelays: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip44(privateRelays.map { RelayTag.assemble(it) }.toTypedArray(), signer), + createdAt = createdAt, + ) { + alt(ALT) + proxyRelays(publicRelays) + + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/TrustedRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/TrustedRelayListEvent.kt new file mode 100644 index 0000000000..7fd8262466 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/TrustedRelayListEvent.kt @@ -0,0 +1,121 @@ +/** + * 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.quartz.nip51Lists.relayLists + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relays +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.trustedRelays +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.plus + +@Immutable +class TrustedRelayListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun publicRelays() = tags.relays() + + suspend fun decryptPrivateRelays(signer: NostrSigner) = privateTags(signer)?.relays() + + suspend fun decryptRelays(signer: NostrSigner): List = publicRelays() + (decryptPrivateRelays(signer) ?: emptyList()) + + companion object { + const val KIND = 10089 + val ALT = "Trusted relays from this author" + val TAGS = arrayOf(AltTag.assemble(ALT)) + + fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, "") + + fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, "", null) + + fun createAddressTag(pubKey: HexKey): String = Address.Companion.assemble(KIND, pubKey, "") + + suspend fun updateRelayList( + earlierVersion: TrustedRelayListEvent, + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): TrustedRelayListEvent { + val newRelayList = relays.map { RelayTag.assemble(it) } + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + + val publicTags = earlierVersion.tags.remove(RelayTag::match) + val newPrivateTags = privateTags.remove(RelayTag::notMatch).plus(newRelayList) + + return signer.signNip51List(createdAt, KIND, publicTags, newPrivateTags) + } + + suspend fun create( + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): TrustedRelayListEvent { + val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() + return signer.signNip51List(createdAt, KIND, TAGS, privateTagArray) + } + + suspend fun create( + relays: List, + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): TrustedRelayListEvent { + val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() + return signer.signNip51List(createdAt, KIND, TAGS, privateTagArray) + } + + suspend fun build( + publicRelays: List = emptyList(), + privateRelays: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip44(privateRelays.map { RelayTag.assemble(it) }.toTypedArray(), signer), + createdAt = createdAt, + ) { + alt(ALT) + trustedRelays(publicRelays) + + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/RelayTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/RelayTag.kt new file mode 100644 index 0000000000..b7c7261cb4 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/RelayTag.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.relayLists.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.ensure + +class RelayTag { + companion object { + const val TAG_NAME = "relay" + + @JvmStatic + fun match(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun notMatch(tag: Array) = tag.has(0) && tag[0] == TAG_NAME + + @JvmStatic + fun parse(tag: Array): NormalizedRelayUrl? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + val relay = RelayUrlNormalizer.normalizeOrNull(tag[1]) ?: return null + + return relay + } + + @JvmStatic + fun assemble(relay: NormalizedRelayUrl) = arrayOf(TAG_NAME, relay.url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..a2ce226df0 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayBuilderExt.kt @@ -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.quartz.nip51Lists.relayLists.tags + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent +import com.vitorpamplona.quartz.nip51Lists.tags.NameTag +import kotlin.collections.map + +fun TagArrayBuilder.name(name: String) = addUnique(NameTag.assemble(name)) + +fun TagArrayBuilder.searchRelays(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) + +fun TagArrayBuilder.blockedRelays(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) + +fun TagArrayBuilder.trustedRelays(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) + +fun TagArrayBuilder.broadcastRelays(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) + +fun TagArrayBuilder.indexerRelays(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) + +fun TagArrayBuilder.proxyRelays(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) + +fun TagArrayBuilder.privateRelays(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) + +fun TagArrayBuilder.relaySet(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayExt.kt new file mode 100644 index 0000000000..35413f65be --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayExt.kt @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.relayLists.tags + +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.relays() = mapNotNull(RelayTag::parse) + +fun TagArray.relaySet() = mapNotNullTo(mutableSetOf(), RelayTag::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relaySets/RelaySetEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relaySets/RelaySetEvent.kt new file mode 100644 index 0000000000..5866237eed --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/relaySets/RelaySetEvent.kt @@ -0,0 +1,116 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.relaySets + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relaySet +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag +import com.vitorpamplona.quartz.nip51Lists.tags.ImageTag +import com.vitorpamplona.quartz.nip51Lists.tags.NameTag +import com.vitorpamplona.quartz.nip51Lists.tags.RelayTag +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class RelaySetEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun relays(): List = tags.mapNotNull(RelayTag.Companion::parse) + + fun name() = tags.firstNotNullOfOrNull(NameTag::parse) + + fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse) + + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) + + companion object { + const val KIND = 30002 + const val ALT = "Relay list" + val ALT_TAG = arrayOf(AltTag.assemble(ALT)) + + suspend fun updateRelayList( + earlierVersion: RelaySetEvent, + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): RelaySetEvent { + val newRelayList = relays.map { RelayTag.assemble(it) } + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + + val publicTags = earlierVersion.tags.remove(RelayTag::match) + val newPrivateTags = privateTags.remove(RelayTag::notMatch).plus(newRelayList) + + return signer.signNip51List(createdAt, KIND, publicTags, newPrivateTags) + } + + suspend fun create( + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): RelaySetEvent { + val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() + return signer.signNip51List(createdAt, KIND, ALT_TAG, privateTagArray) + } + + suspend fun create( + relays: List, + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): RelaySetEvent { + val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() + return signer.signNip51List(createdAt, KIND, ALT_TAG, privateTagArray) + } + + suspend fun build( + publicRelays: List = emptyList(), + privateRelays: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip44(privateRelays.map { RelayTag.assemble(it) }.toTypedArray(), signer), + createdAt = createdAt, + ) { + alt(ALT) + relaySet(publicRelays) + + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PodcastFeedId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/tags/DescriptionTag.kt similarity index 71% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PodcastFeedId.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/tags/DescriptionTag.kt index f1fe33442d..ee956a08c4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PodcastFeedId.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/tags/DescriptionTag.kt @@ -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,22 +18,19 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip73ExternalIds - -class PodcastFeedId( - val guid: String, - val hint: String? = null, -) : ExternalId { - override fun toScope() = toScope(guid) - - override fun toKind() = toKind(guid) - - override fun hint() = hint +package com.vitorpamplona.quartz.nip51Lists.tags +class DescriptionTag { companion object { - // "isbn:9780765382030" - fun toScope(guid: String) = "podcast:guid:" + guid + const val TAG_NAME = "description" - fun toKind(guid: String) = "podcast:guid" + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < 2 || tag[0] != TAG_NAME || tag[1].isEmpty()) return null + return tag[1] + } + + @JvmStatic + fun assemble(name: String) = arrayOf(TAG_NAME, name) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PaperId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/tags/ImageTag.kt similarity index 72% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PaperId.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/tags/ImageTag.kt index 1a47e57491..90bb59757d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PaperId.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/tags/ImageTag.kt @@ -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,22 +18,19 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip73ExternalIds - -class PaperId( - val doi: String, - val hint: String? = null, -) : ExternalId { - override fun toScope() = toScope(doi) - - override fun toKind() = toKind(doi) - - override fun hint() = hint +package com.vitorpamplona.quartz.nip51Lists.tags +class ImageTag { companion object { - // "doi:10.1000/182" - fun toScope(doi: String) = "doi:" + doi.lowercase() + const val TAG_NAME = "image" - fun toKind(doi: String) = "doi" + @JvmStatic + fun parse(tag: Array): String? { + if (tag.size < 2 || tag[0] != TAG_NAME || tag[1].isEmpty()) return null + return tag[1] + } + + @JvmStatic + fun assemble(name: String) = arrayOf(TAG_NAME, name) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/tags/NameTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/tags/NameTag.kt index 6935c0782a..bb32b09212 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/tags/NameTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/tags/NameTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/tags/RelayTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/tags/RelayTag.kt new file mode 100644 index 0000000000..50b700f645 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/tags/RelayTag.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.ensure + +class RelayTag { + companion object { + const val TAG_NAME = "r" + + @JvmStatic + fun match(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun notMatch(tag: Array) = tag.has(0) && tag[0] == TAG_NAME + + @JvmStatic + fun parse(tag: Array): NormalizedRelayUrl? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + val relay = RelayUrlNormalizer.normalizeOrNull(tag[1]) ?: return null + + return relay + } + + @JvmStatic + fun assemble(relay: NormalizedRelayUrl) = arrayOf(TAG_NAME, relay.url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/tags/TitleTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/tags/TitleTag.kt index 9b46e5ef55..5687d5fbf5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/tags/TitleTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/tags/TitleTag.kt @@ -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,7 +20,6 @@ */ package com.vitorpamplona.quartz.nip51Lists.tags -@Deprecated("Use NameTag Instead") class TitleTag { companion object { const val TAG_NAME = "title" diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarDateSlotEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarDateSlotEvent.kt index 29a5210d95..6e74d196f0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarDateSlotEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarDateSlotEvent.kt @@ -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 @@ -50,13 +50,12 @@ class CalendarDateSlotEvent( const val KIND = 31922 const val ALT = "Full-day calendar event" - fun create( + suspend fun create( signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (CalendarDateSlotEvent) -> Unit, - ) { + ): CalendarDateSlotEvent { val tags = arrayOf(AltTag.assemble(ALT)) - signer.sign(createdAt, KIND, tags, "", onReady) + return signer.sign(createdAt, KIND, tags, "") } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarEvent.kt index 8516598156..9bec27678c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarEvent.kt @@ -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 @@ -40,13 +40,12 @@ class CalendarEvent( const val KIND = 31924 const val ALT = "Calendar" - fun create( + suspend fun create( signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (CalendarEvent) -> Unit, - ) { + ): CalendarEvent { val tags = arrayOf(AltTag.assemble(ALT)) - signer.sign(createdAt, KIND, tags, "", onReady) + return signer.sign(createdAt, KIND, tags, "") } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarRSVPEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarRSVPEvent.kt index cc0e8f3bcc..700f7a5663 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarRSVPEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarRSVPEvent.kt @@ -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 @@ -52,13 +52,12 @@ class CalendarRSVPEvent( const val KIND = 31925 const val ALT = "Calendar event's invitation response" - fun create( + suspend fun create( signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (CalendarRSVPEvent) -> Unit, - ) { + ): CalendarRSVPEvent { val tags = arrayOf(AltTag.assemble(ALT)) - signer.sign(createdAt, KIND, tags, "", onReady) + return signer.sign(createdAt, KIND, tags, "") } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarTimeSlotEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarTimeSlotEvent.kt index db2621c21d..2b9b5e2e37 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarTimeSlotEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip52Calendar/CalendarTimeSlotEvent.kt @@ -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,13 +57,12 @@ class CalendarTimeSlotEvent( const val KIND = 31923 const val ALT = "Calendar time-slot event" - fun create( + suspend fun create( signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (CalendarTimeSlotEvent) -> Unit, - ) { + ): CalendarTimeSlotEvent { val tags = arrayOf(AltTag.assemble(ALT)) - signer.sign(createdAt, KIND, tags, "", onReady) + return signer.sign(createdAt, KIND, tags, "") } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/chat/LiveActivitiesChatMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/chat/LiveActivitiesChatMessageEvent.kt index 5444f2f83c..1ecedc91ef 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/chat/LiveActivitiesChatMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/chat/LiveActivitiesChatMessageEvent.kt @@ -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 @@ -28,6 +28,9 @@ import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.aTag @@ -36,7 +39,14 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip10Notes.tags.markedETag import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip19Bech32.addressHints +import com.vitorpamplona.quartz.nip19Bech32.addressIds +import com.vitorpamplona.quartz.nip19Bech32.eventHints +import com.vitorpamplona.quartz.nip19Bech32.eventIds +import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints +import com.vitorpamplona.quartz.nip19Bech32.pubKeys import com.vitorpamplona.quartz.nip37Drafts.ExposeInDraft +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -52,12 +62,55 @@ class LiveActivitiesChatMessageEvent( EventHintProvider, PubKeyHintProvider, AddressHintProvider, - ExposeInDraft { - override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + ExposeInDraft, + SearchableEvent { + override fun indexableContent() = content - override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + tags.mapNotNull(QTag::parseEventAsHint) + override fun eventHints(): List { + val eHints = tags.mapNotNull(ETag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseEventAsHint) + val nip19Hints = citedNIP19().eventHints() - override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + tags.mapNotNull(QTag::parseAddressAsHint) + return eHints + qHints + nip19Hints + } + + override fun linkedEventIds(): List { + val eHints = tags.mapNotNull(ETag::parseId) + val qHints = tags.mapNotNull(QTag::parseEventId) + val nip19Hints = citedNIP19().eventIds() + + return eHints + qHints + nip19Hints + } + + override fun addressHints(): List { + val aHints = tags.mapNotNull(ATag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseAddressAsHint) + val nip19Hints = citedNIP19().addressHints() + + return aHints + qHints + nip19Hints + } + + override fun linkedAddressIds(): List { + val aHints = tags.mapNotNull(ATag::parseAddressId) + val qHints = tags.mapNotNull(QTag::parseAddressId) + val nip19Hints = citedNIP19().addressIds() + + return aHints + qHints + nip19Hints + } + + override fun pubKeyHints(): List { + val pHints = tags.mapNotNull(PTag::parseAsHint) + val nip19Hints = citedNIP19().pubKeyHints() + + return pHints + nip19Hints + } + + override fun linkedPubKeys(): List { + val pHints = tags.mapNotNull(PTag::parseKey) + val nip19Hints = citedNIP19().pubKeys() + + return pHints + nip19Hints + } private fun activityHex() = tags.firstNotNullOfOrNull(ATag::parseAddressId) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/chat/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/chat/TagArrayBuilderExt.kt index 07302df2c0..05814441c1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/chat/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/chat/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt index b94e2a0cd8..23c919fa6b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt @@ -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,13 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.any +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag @@ -47,7 +53,22 @@ class LiveActivitiesEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + tags.mapNotNull(QTag::parseEventAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + tags.mapNotNull(QTag::parseEventId) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + tags.mapNotNull(QTag::parseAddressAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + tags.mapNotNull(QTag::parseAddressId) + + override fun pubKeyHints() = tags.mapNotNull(ParticipantTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(ParticipantTag::parseKey) + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse) @@ -60,7 +81,9 @@ class LiveActivitiesEvent( fun ends() = tags.firstNotNullOfOrNull(EndsTag::parse) - fun status() = checkStatus(tags.firstNotNullOfOrNull(StatusTag::parse)) + fun status() = checkStatus(tags.firstNotNullOfOrNull(StatusTag::parseEnum)) + + fun isLive() = status() == StatusTag.STATUS.LIVE fun currentParticipants() = tags.firstNotNullOfOrNull(CurrentParticipantsTag::parse) @@ -70,7 +93,7 @@ class LiveActivitiesEvent( fun relays() = tags.mapNotNull(RelayListTag::parse) - fun allRelayUrls() = tags.mapNotNull(RelayListTag::parse).map { it.relayUrls }.flatten() + fun allRelayUrls() = tags.mapNotNull(RelayListTag::parse).flatten() fun hasHost() = tags.any(ParticipantTag::isHost) @@ -78,9 +101,19 @@ class LiveActivitiesEvent( fun hosts() = tags.mapNotNull(ParticipantTag::parseHost) - fun checkStatus(eventStatus: String?): String? = - if (eventStatus == StatusTag.STATUS.LIVE.code && createdAt < TimeUtils.eightHoursAgo()) { - StatusTag.STATUS.ENDED.code + fun checkStatus(eventStatus: StatusTag.STATUS?): StatusTag.STATUS? = + if (eventStatus == StatusTag.STATUS.LIVE && createdAt < TimeUtils.eightHoursAgo()) { + StatusTag.STATUS.ENDED + } else if (eventStatus == StatusTag.STATUS.PLANNED) { + val starts = starts() + val ends = ends() + if (starts != null && starts < TimeUtils.oneHourAgo()) { + StatusTag.STATUS.ENDED + } else if (ends != null && ends < TimeUtils.oneHourAgo()) { + StatusTag.STATUS.ENDED + } else { + eventStatus + } } else { eventStatus } @@ -91,13 +124,12 @@ class LiveActivitiesEvent( const val KIND = 30311 const val ALT = "Live activity event" - fun create( + suspend fun create( signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (LiveActivitiesEvent) -> Unit, - ) { + ): LiveActivitiesEvent { val tags = arrayOf(AltTag.assemble(ALT)) - signer.sign(createdAt, KIND, tags, "", onReady) + return signer.sign(createdAt, KIND, tags, "") } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/CurrentParticipantsTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/CurrentParticipantsTag.kt index fb1b44148f..3891a7cad9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/CurrentParticipantsTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/CurrentParticipantsTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/EndsTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/EndsTag.kt index 26d2125478..ea7253cc80 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/EndsTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/EndsTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/ParticipantTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/ParticipantTag.kt index c48c004c39..5478fedf1b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/ParticipantTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/ParticipantTag.kt @@ -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,6 +24,9 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -38,7 +41,7 @@ enum class ROLE( @Immutable data class ParticipantTag( override val pubKey: String, - override val relayHint: String?, + override val relayHint: NormalizedRelayUrl?, val role: String?, val proof: String?, ) : PubKeyReferenceTag { @@ -64,7 +67,10 @@ data class ParticipantTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return ParticipantTag(tag[1], tag.getOrNull(2), tag.getOrNull(3), tag.getOrNull(4)) + + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return ParticipantTag(tag[1], hint, tag.getOrNull(3), tag.getOrNull(4)) } @JvmStatic @@ -73,7 +79,10 @@ data class ParticipantTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } ensure(tag[3] == ROLE.HOST.code) { return null } - return ParticipantTag(tag[1], tag[2], tag[3], tag.getOrNull(4)) + + val hint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + + return ParticipantTag(tag[1], hint, tag[3], tag.getOrNull(4)) } @JvmStatic @@ -84,6 +93,19 @@ data class ParticipantTag( return tag[1] } + @JvmStatic + fun parseAsHint(tag: Array): PubKeyHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val hint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(hint != null) { return null } + + return PubKeyHint(tag[1], hint) + } + @JvmStatic fun assemble( pubkey: HexKey, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/RelayListTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/RelayListTag.kt index 70627f607e..e158c766f8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/RelayListTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/RelayListTag.kt @@ -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,30 +21,30 @@ package com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.utils.ensure -class RelayListTag( - val relayUrls: List, -) { +class RelayListTag { companion object { const val TAG_NAME = "relays" @JvmStatic - fun parse(tag: Array): RelayListTag? { + fun parse(tag: Array): List? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } val relays = tag.mapIndexedNotNull { index, s -> - if (index == 0) null else s + if (index == 0) null else RelayUrlNormalizer.normalizeOrNull(s) } - return RelayListTag(relays) + + if (relays.isEmpty()) return null + + return relays } @JvmStatic - fun assemble(urls: List) = arrayOf(TAG_NAME) + urls.toTypedArray() - - @JvmStatic - fun assemble(tag: RelayListTag) = assemble(tag.relayUrls) + fun assemble(urls: List) = arrayOf(TAG_NAME) + urls.map { it.url }.toTypedArray() } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/StartsTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/StartsTag.kt index 7a094d6433..e9007ac37e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/StartsTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/StartsTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/StatusTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/StatusTag.kt index f41dcfc055..37f65ba403 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/StatusTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/StatusTag.kt @@ -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 @@ -33,6 +33,16 @@ class StatusTag { ; fun toTagArray() = assemble(this) + + companion object { + fun parse(code: String): STATUS? = + when (code) { + LIVE.code -> LIVE + PLANNED.code -> PLANNED + ENDED.code -> ENDED + else -> null + } + } } companion object { @@ -46,6 +56,14 @@ class StatusTag { return tag[1] } + @JvmStatic + fun parseEnum(tag: Array): STATUS? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return STATUS.parse(tag[1]) + } + @JvmStatic fun assemble(status: STATUS) = arrayOf(TAG_NAME, status.code) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/StreamingTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/StreamingTag.kt index 667cf742e8..432e78a7ea 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/StreamingTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/StreamingTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/TotalParticipantsTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/TotalParticipantsTag.kt index ac34c4bf61..8a03816440 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/TotalParticipantsTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/TotalParticipantsTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt index abb9192f85..d0996a1fce 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt @@ -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,13 +23,30 @@ package com.vitorpamplona.quartz.nip54Wiki import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip19Bech32.addressHints +import com.vitorpamplona.quartz.nip19Bech32.addressIds +import com.vitorpamplona.quartz.nip19Bech32.eventHints +import com.vitorpamplona.quartz.nip19Bech32.eventIds +import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints +import com.vitorpamplona.quartz.nip19Bech32.pubKeys import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -41,10 +58,62 @@ class WikiNoteEvent( content: String, sig: HexKey, ) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig), - AddressableEvent { + AddressableEvent, + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider, + SearchableEvent { + override fun indexableContent() = "title: " + title() + "\nsummary: " + summary() + "\n" + content + + override fun eventHints(): List { + val eHints = tags.mapNotNull(MarkedETag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseEventAsHint) + val nip19Hints = citedNIP19().eventHints() + + return eHints + qHints + nip19Hints + } + + override fun linkedEventIds(): List { + val eHints = tags.mapNotNull(MarkedETag::parseId) + val qHints = tags.mapNotNull(QTag::parseEventId) + val nip19Hints = citedNIP19().eventIds() + + return eHints + qHints + nip19Hints + } + + override fun addressHints(): List { + val aHints = tags.mapNotNull(ATag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseAddressAsHint) + val nip19Hints = citedNIP19().addressHints() + + return aHints + qHints + nip19Hints + } + + override fun linkedAddressIds(): List { + val aHints = tags.mapNotNull(ATag::parseAddressId) + val qHints = tags.mapNotNull(QTag::parseAddressId) + val nip19Hints = citedNIP19().addressIds() + + return aHints + qHints + nip19Hints + } + + override fun pubKeyHints(): List { + val pHints = tags.mapNotNull(PTag::parseAsHint) + val nip19Hints = citedNIP19().pubKeyHints() + + return pHints + nip19Hints + } + + override fun linkedPubKeys(): List { + val pHints = tags.mapNotNull(PTag::parseKey) + val nip19Hints = citedNIP19().pubKeys() + + return pHints + nip19Hints + } + override fun dTag() = tags.dTag() - override fun aTag(relayHint: String?) = ATag(kind, pubKey, dTag(), relayHint) + override fun aTag(relayHint: NormalizedRelayUrl?) = ATag(kind, pubKey, dTag(), relayHint) override fun address() = Address(kind, pubKey, dTag()) @@ -68,21 +137,20 @@ class WikiNoteEvent( companion object { const val KIND = 30818 - fun create( + suspend fun create( msg: String, title: String?, replyTos: List?, mentions: List?, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (WikiNoteEvent) -> Unit, - ) { + ): WikiNoteEvent { val tags = mutableListOf>() replyTos?.forEach { tags.add(arrayOf("e", it)) } mentions?.forEach { tags.add(arrayOf("p", it)) } title?.let { tags.add(arrayOf("title", it)) } tags.add(AltTag.assemble("Wiki Post: $title")) - signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) + return signer.sign(createdAt, KIND, tags.toTypedArray(), msg) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/ExternalSignerLauncher.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/ExternalSignerLauncher.kt deleted file mode 100644 index fd59617c0b..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/ExternalSignerLauncher.kt +++ /dev/null @@ -1,435 +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.quartz.nip55AndroidSigner - -import android.content.ContentResolver -import android.content.Intent -import android.net.Uri -import android.util.Log -import android.util.LruCache -import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.core.JsonParser -import com.fasterxml.jackson.databind.DeserializationContext -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.deser.std.StdDeserializer -import com.fasterxml.jackson.databind.module.SimpleModule -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent - -enum class SignerType { - SIGN_EVENT, - NIP04_ENCRYPT, - NIP04_DECRYPT, - NIP44_ENCRYPT, - NIP44_DECRYPT, - GET_PUBLIC_KEY, - DECRYPT_ZAP_EVENT, - DERIVE_KEY, -} - -class Permission( - val type: String, - val kind: Int? = null, -) { - fun toJson(): String = "{\"type\":\"${type}\",\"kind\":$kind}" -} - -class Result( - @JsonProperty("package") val `package`: String?, - @JsonProperty("signature") val signature: String?, - @JsonProperty("result") val result: String?, - @JsonProperty("id") val id: String?, -) { - companion object { - val mapper = - jacksonObjectMapper() - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - .registerModule( - SimpleModule().addDeserializer(Result::class.java, ResultDeserializer()), - ) - - private class ResultDeserializer : StdDeserializer(Result::class.java) { - override fun deserialize( - jp: JsonParser, - ctxt: DeserializationContext, - ): Result { - val jsonObject: JsonNode = jp.codec.readTree(jp) - return Result( - jsonObject.get("package").asText().intern(), - jsonObject.get("signature")?.asText()?.intern(), - jsonObject.get("result")?.asText()?.intern(), - jsonObject.get("id").asText().intern(), - ) - } - } - - fun fromJson(json: String): Result = mapper.readValue(json, Result::class.java) - - /** - * Parses the json with a string of events to an Array of Event objects. - */ - fun fromJsonArray(json: String): Array = mapper.readValue(json) - } -} - -class ExternalSignerLauncher( - private val currentUserPubKeyHex: String, - val signerPackageName: String, -) { - private val contentCache = LruCache Unit>(50) - - private var signerAppLauncher: ((Intent) -> Unit)? = null - private var contentResolver: (() -> ContentResolver)? = null - - /** Call this function when the launcher becomes available on activity, fragment or compose */ - fun registerLauncher( - launcher: ((Intent) -> Unit), - contentResolver: (() -> ContentResolver), - ) { - this.signerAppLauncher = launcher - this.contentResolver = contentResolver - } - - /** Call this function when the activity is destroyed or is about to be replaced. */ - fun clearLauncher() { - this.signerAppLauncher = null - this.contentResolver = null - } - - fun newResult(data: Intent) { - val results = data.getStringExtra("results") - if (results != null) { - val localResults: Array = Result.fromJsonArray(results) - localResults.forEach { - val signature = it.result ?: it.signature ?: "" - val packageName = it.`package`?.let { "-$it" } ?: "" - val id = it.id ?: "" - if (id.isNotBlank()) { - val result = if (packageName.isNotBlank()) "$signature$packageName" else signature - val contentCache = contentCache.get(id) - contentCache?.invoke(result) - } - } - } else { - val signature = data.getStringExtra("result") ?: data.getStringExtra("signature") ?: "" - val packageName = data.getStringExtra("package")?.let { "-$it" } ?: "" - val id = data.getStringExtra("id") ?: "" - if (id.isNotBlank()) { - val result = if (packageName.isNotBlank()) "$signature$packageName" else signature - val contentCache = contentCache.get(id) - contentCache?.invoke(result) - } - } - } - - fun openSignerApp( - data: String, - type: SignerType, - pubKey: HexKey, - id: String, - onReady: (String) -> Unit, - ) { - signerAppLauncher?.let { - openSignerApp( - data, - type, - it, - pubKey, - id, - onReady, - ) - } - } - - private fun defaultPermissions(): String { - val permissions = - listOf( - Permission( - "sign_event", - 22242, - ), - Permission( - "sign_event", - 31234, - ), - Permission( - "nip04_encrypt", - ), - Permission( - "nip04_decrypt", - ), - Permission( - "nip44_encrypt", - ), - Permission( - "nip44_decrypt", - ), - Permission( - "decrypt_zap_event", - ), - ) - val jsonArray = StringBuilder("[") - permissions.forEachIndexed { index, permission -> - jsonArray.append(permission.toJson()) - if (index < permissions.size - 1) { - jsonArray.append(",") - } - } - jsonArray.append("]") - - return jsonArray.toString() - } - - private fun openSignerApp( - data: String, - type: SignerType, - intentLauncher: (Intent) -> Unit, - pubKey: HexKey, - id: String, - onReady: (String) -> Unit, - ) { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:$data")) - val signerType = - when (type) { - SignerType.SIGN_EVENT -> "sign_event" - SignerType.NIP04_ENCRYPT -> "nip04_encrypt" - SignerType.NIP04_DECRYPT -> "nip04_decrypt" - SignerType.NIP44_ENCRYPT -> "nip44_encrypt" - SignerType.NIP44_DECRYPT -> "nip44_decrypt" - SignerType.GET_PUBLIC_KEY -> "get_public_key" - SignerType.DECRYPT_ZAP_EVENT -> "decrypt_zap_event" - SignerType.DERIVE_KEY -> "derive_key" - } - intent.putExtra("type", signerType) - intent.putExtra("pubKey", pubKey) - intent.putExtra("id", id) - if (type !== SignerType.GET_PUBLIC_KEY) { - intent.putExtra("current_user", currentUserPubKeyHex) - } else { - intent.putExtra("permissions", defaultPermissions()) - } - if (signerPackageName.isNotBlank()) { - intent.`package` = signerPackageName - } - - intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) - - contentCache.put(id, onReady) - - intentLauncher(intent) - } - - fun openSigner( - event: Event, - onReady: (String) -> Unit, - ) { - getDataFromResolver( - SignerType.SIGN_EVENT, - arrayOf(event.toJson(), event.pubKey), - ).fold( - onFailure = { }, - onSuccess = { - if (it == null) { - openSignerApp( - event.toJson(), - SignerType.SIGN_EVENT, - "", - event.id, - onReady, - ) - } else { - onReady(it) - } - }, - ) - } - - private fun getDataFromResolver( - signerType: SignerType, - data: Array, - ): kotlin.Result = getDataFromResolver(signerType, data, contentResolver) - - private fun getDataFromResolver( - signerType: SignerType, - data: Array, - contentResolver: (() -> ContentResolver)? = null, - ): kotlin.Result { - val localData = - if (signerType !== SignerType.GET_PUBLIC_KEY) { - arrayOf(*data, currentUserPubKeyHex) - } else { - data - } - - try { - contentResolver - ?.let { it() } - ?.query( - Uri.parse("content://$signerPackageName.$signerType"), - localData, - "1", - null, - null, - ).use { - if (it == null) { - return kotlin.Result.success(null) - } - if (it.moveToFirst()) { - if (it.getColumnIndex("rejected") > -1) { - Log.d("getDataFromResolver", "Permission denied") - return kotlin.Result.failure(Exception("Permission denied")) - } - var index = it.getColumnIndex("result") - if (index < 0) { - index = it.getColumnIndex("signature") - } - if (index < 0) { - Log.d("getDataFromResolver", "column 'signature' not found") - return kotlin.Result.success(null) - } - return kotlin.Result.success(it.getString(index)) - } - } - } catch (e: Exception) { - Log.e("ExternalSignerLauncher", "Failed to query the Signer app in the background") - return kotlin.Result.success(null) - } - - return kotlin.Result.success(null) - } - - fun hashCodeFields( - str1: String, - onReady: (String) -> Unit, - ): Int { - var result = str1.hashCode() - result = 31 * result + onReady.hashCode() - return result - } - - fun hashCodeFields( - str1: String, - str2: String, - onReady: (String) -> Unit, - ): Int { - var result = str1.hashCode() - result = 31 * result + str2.hashCode() - result = 31 * result + onReady.hashCode() - return result - } - - fun decrypt( - encryptedContent: String, - pubKey: HexKey, - signerType: SignerType = SignerType.NIP04_DECRYPT, - onReady: (String) -> Unit, - ) { - getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey)).fold( - onFailure = { }, - onSuccess = { - if (it == null) { - openSignerApp( - encryptedContent, - signerType, - pubKey, - hashCodeFields(encryptedContent, pubKey, onReady).toString(), - onReady, - ) - } else { - onReady(it) - } - }, - ) - } - - fun encrypt( - decryptedContent: String, - pubKey: HexKey, - signerType: SignerType = SignerType.NIP04_ENCRYPT, - onReady: (String) -> Unit, - ) { - getDataFromResolver(signerType, arrayOf(decryptedContent, pubKey)).fold( - onFailure = { }, - onSuccess = { - if (it == null) { - openSignerApp( - decryptedContent, - signerType, - pubKey, - hashCodeFields(decryptedContent, pubKey, onReady).toString(), - onReady, - ) - } else { - onReady(it) - } - }, - ) - } - - fun decryptZapEvent( - event: LnZapRequestEvent, - onReady: (String) -> Unit, - ) { - getDataFromResolver(SignerType.DECRYPT_ZAP_EVENT, arrayOf(event.toJson(), event.pubKey)).fold( - onFailure = { }, - onSuccess = { - if (it == null) { - openSignerApp( - event.toJson(), - SignerType.DECRYPT_ZAP_EVENT, - event.pubKey, - event.id, - onReady, - ) - } else { - onReady(it) - } - }, - ) - } - - fun deriveKey( - nonce: HexKey, - signerType: SignerType = SignerType.DERIVE_KEY, - onReady: (String) -> Unit, - ) { - getDataFromResolver(signerType, arrayOf(nonce)).fold( - onFailure = { }, - onSuccess = { - if (it == null) { - openSignerApp( - nonce, - signerType, - "", - hashCodeFields(nonce, onReady).toString(), - onReady, - ) - } else { - onReady(it) - } - }, - ) - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/NostrSignerExternal.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/NostrSignerExternal.kt deleted file mode 100644 index bf4c2164d4..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/NostrSignerExternal.kt +++ /dev/null @@ -1,159 +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.quartz.nip55AndroidSigner - -import android.util.Log -import com.vitorpamplona.quartz.EventFactory -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent - -class NostrSignerExternal( - pubKey: HexKey, - val launcher: ExternalSignerLauncher, -) : NostrSigner(pubKey) { - override fun sign( - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - onReady: (T) -> Unit, - ) { - val event = - Event( - id = EventHasher.hashId(pubKey, createdAt, kind, tags, content), - pubKey = pubKey, - createdAt = createdAt, - kind = kind, - tags = tags, - content = content, - sig = "", - ) - - launcher.openSigner(event) { signature -> - if (signature.startsWith("{")) { - val localEvent = Event.fromJson(signature) - ( - EventFactory.create( - localEvent.id, - localEvent.pubKey, - localEvent.createdAt, - localEvent.kind, - localEvent.tags, - localEvent.content, - localEvent.sig, - ) as? T? - )?.let { onReady(it) } - } else { - ( - EventFactory.create( - event.id, - event.pubKey, - event.createdAt, - event.kind, - event.tags, - event.content, - signature.split("-")[0], - ) as? T? - )?.let { onReady(it) } - } - } - } - - override fun nip04Encrypt( - decryptedContent: String, - toPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - launcher.encrypt( - decryptedContent, - toPublicKey, - SignerType.NIP04_ENCRYPT, - onReady, - ) - } - - override fun nip04Decrypt( - encryptedContent: String, - fromPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - launcher.decrypt( - encryptedContent, - fromPublicKey, - SignerType.NIP04_DECRYPT, - onReady, - ) - } - - override fun nip44Encrypt( - decryptedContent: String, - toPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - launcher.encrypt( - decryptedContent, - toPublicKey, - SignerType.NIP44_ENCRYPT, - onReady, - ) - } - - override fun nip44Decrypt( - encryptedContent: String, - fromPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - launcher.decrypt( - encryptedContent, - fromPublicKey, - SignerType.NIP44_DECRYPT, - onReady, - ) - } - - override fun deriveKey( - nonce: HexKey, - onReady: (HexKey) -> Unit, - ) { - launcher.deriveKey( - nonce, - SignerType.DERIVE_KEY, - onReady, - ) - } - - override fun decryptZapEvent( - event: LnZapRequestEvent, - onReady: (LnZapPrivateEvent) -> Unit, - ) { - launcher.decryptZapEvent(event) { jsonEvent -> - try { - (Event.fromJson(jsonEvent) as? LnZapPrivateEvent)?.let { onReady(it) } - } catch (e: Exception) { - Log.e("NostrExternalSigner", "Unable to parse returned decrypted Zap: $jsonEvent") - } - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/SignString.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/SignString.kt index d96718340f..d56d8c9a42 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/SignString.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/SignString.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/CommandType.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/CommandType.kt new file mode 100644 index 0000000000..4aadd4a8ad --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/CommandType.kt @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api + +enum class CommandType( + val code: String, +) { + SIGN_EVENT("sign_event"), + NIP04_ENCRYPT("nip04_encrypt"), + NIP04_DECRYPT("nip04_decrypt"), + NIP44_ENCRYPT("nip44_encrypt"), + NIP44_DECRYPT("nip44_decrypt"), + GET_PUBLIC_KEY("get_public_key"), + DECRYPT_ZAP_EVENT("decrypt_zap_event"), + DERIVE_KEY("derive_key"), + ; + + companion object { + fun parse(code: String): CommandType? = + when (code) { + SIGN_EVENT.code -> SIGN_EVENT + NIP04_ENCRYPT.code -> NIP04_ENCRYPT + NIP04_DECRYPT.code -> NIP04_DECRYPT + NIP44_ENCRYPT.code -> NIP44_ENCRYPT + NIP44_DECRYPT.code -> NIP44_DECRYPT + GET_PUBLIC_KEY.code -> GET_PUBLIC_KEY + DECRYPT_ZAP_EVENT.code -> DECRYPT_ZAP_EVENT + DERIVE_KEY.code -> DERIVE_KEY + else -> null + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/SignerResult.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/SignerResult.kt new file mode 100644 index 0000000000..859c52b35f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/SignerResult.kt @@ -0,0 +1,91 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent + +sealed interface SignerResult { + sealed interface RequestAddressed : SignerResult { + class Successful( + val result: T, + ) : RequestAddressed + + class ManuallyRejected : RequestAddressed + + class AutomaticallyRejected : RequestAddressed + + class TimedOut : RequestAddressed + + class NoActivityToLaunchFrom : RequestAddressed + + class SignerNotFound : RequestAddressed + + class ReceivedButCouldNotPerform( + val message: String? = null, + ) : RequestAddressed + + class ReceivedButCouldNotParseEventFromResult( + val eventJson: String, + ) : RequestAddressed + + class ReceivedButCouldNotVerifyResultingEvent( + val invalidEvent: Event, + ) : RequestAddressed + } + + sealed interface RequestIncomplete : SignerResult { + class RequiresManualApproval : RequestIncomplete + + class ErrorExceptionCallingContentResolver( + val e: Exception? = null, + ) : SignerResult, + RequestIncomplete + } +} + +interface IResult + +data class PubKeyResult( + val pubkey: HexKey, + val packageName: String, +) : IResult + +data class SignResult( + val event: Event, +) : IResult + +data class EncryptionResult( + val ciphertext: String, +) : IResult + +data class DecryptionResult( + val plaintext: String, +) : IResult + +data class ZapEventDecryptionResult( + val privateEvent: LnZapPrivateEvent, +) : IResult + +data class DerivationResult( + val newPrivKey: HexKey, +) : IResult diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/DecryptZapQuery.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/DecryptZapQuery.kt new file mode 100644 index 0000000000..9e496471ea --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/DecryptZapQuery.kt @@ -0,0 +1,65 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries + +import android.content.ContentResolver +import androidx.core.net.toUri +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.verify +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.ZapEventDecryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.getStringByName +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.query +import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent + +class DecryptZapQuery( + val loggedInUser: HexKey, + val packageName: String, + val contentResolver: ContentResolver, +) { + fun query(event: Event): SignerResult = + contentResolver.query( + "content://$packageName.${CommandType.DECRYPT_ZAP_EVENT}".toUri(), + arrayOf(event.toJson(), event.pubKey, loggedInUser), + ) { cursor -> + val decryptedEventAsJson = cursor.getStringByName("result") + if (!decryptedEventAsJson.isNullOrBlank()) { + if (decryptedEventAsJson.startsWith("{")) { + val event = Event.fromJsonOrNull(decryptedEventAsJson) as? LnZapPrivateEvent + if (event != null) { + if (event.verify()) { + SignerResult.RequestAddressed.Successful(ZapEventDecryptionResult(event)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotVerifyResultingEvent(event) + } + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotParseEventFromResult(decryptedEventAsJson) + } + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform(decryptedEventAsJson) + } + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform(decryptedEventAsJson) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/DeriveKeyQuery.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/DeriveKeyQuery.kt new file mode 100644 index 0000000000..e2fe9a4606 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/DeriveKeyQuery.kt @@ -0,0 +1,51 @@ +/** + * 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.quartz.nip55AndroidSigner.api.background.queries + +import android.content.ContentResolver +import androidx.core.net.toUri +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType +import com.vitorpamplona.quartz.nip55AndroidSigner.api.DerivationResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.getStringByName +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.query + +class DeriveKeyQuery( + val loggedInUser: HexKey, + val packageName: String, + val contentResolver: ContentResolver, +) { + val uri = "content://$packageName.${CommandType.DERIVE_KEY}".toUri() + + fun query(nonce: HexKey): SignerResult = + contentResolver.query( + uri, + arrayOf(nonce, loggedInUser), + ) { cursor -> + val newPrivateKey = cursor.getStringByName("result") + if (!newPrivateKey.isNullOrBlank()) { + SignerResult.RequestAddressed.Successful(DerivationResult(newPrivateKey)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/LoginQuery.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/LoginQuery.kt new file mode 100644 index 0000000000..b86099b2ba --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/LoginQuery.kt @@ -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.quartz.nip55AndroidSigner.api.background.queries + +import android.content.ContentResolver +import androidx.core.net.toUri +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType +import com.vitorpamplona.quartz.nip55AndroidSigner.api.PubKeyResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.getStringByName +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.query + +class LoginQuery( + val packageName: String, + val contentResolver: ContentResolver, +) { + companion object { + val LOGIN = arrayOf("login") + } + + val uri = "content://$packageName.${CommandType.GET_PUBLIC_KEY}".toUri() + + fun query(): SignerResult = + contentResolver.query( + uri, + LOGIN, + ) { cursor -> + val pubkeyHex = cursor.getStringByName("result") + if (!pubkeyHex.isNullOrBlank()) { + SignerResult.RequestAddressed.Successful(PubKeyResult(pubkeyHex, packageName)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/Nip04DecryptQuery.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/Nip04DecryptQuery.kt new file mode 100644 index 0000000000..ed7b13d080 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/Nip04DecryptQuery.kt @@ -0,0 +1,54 @@ +/** + * 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.quartz.nip55AndroidSigner.api.background.queries + +import android.content.ContentResolver +import androidx.core.net.toUri +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType +import com.vitorpamplona.quartz.nip55AndroidSigner.api.DecryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.getStringByName +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.query + +class Nip04DecryptQuery( + val loggedInUser: HexKey, + val packageName: String, + val contentResolver: ContentResolver, +) { + val uri = "content://$packageName.${CommandType.NIP04_DECRYPT}".toUri() + + fun query( + ciphertext: String, + fromPubKey: HexKey, + ): SignerResult = + contentResolver.query( + uri, + arrayOf(ciphertext, fromPubKey, loggedInUser), + ) { cursor -> + val plaintext = cursor.getStringByName("result") + if (!plaintext.isNullOrBlank()) { + SignerResult.RequestAddressed.Successful(DecryptionResult(plaintext)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/Nip04EncryptQuery.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/Nip04EncryptQuery.kt new file mode 100644 index 0000000000..a318019f71 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/Nip04EncryptQuery.kt @@ -0,0 +1,54 @@ +/** + * 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.quartz.nip55AndroidSigner.api.background.queries + +import android.content.ContentResolver +import androidx.core.net.toUri +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType +import com.vitorpamplona.quartz.nip55AndroidSigner.api.EncryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.getStringByName +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.query + +class Nip04EncryptQuery( + val loggedInUser: HexKey, + val packageName: String, + val contentResolver: ContentResolver, +) { + val uri = "content://$packageName.${CommandType.NIP04_ENCRYPT}".toUri() + + fun query( + plaintext: String, + toPubKey: HexKey, + ): SignerResult = + contentResolver.query( + uri, + arrayOf(plaintext, toPubKey, loggedInUser), + ) { cursor -> + val ciphertext = cursor.getStringByName("result") + if (!ciphertext.isNullOrBlank()) { + SignerResult.RequestAddressed.Successful(EncryptionResult(ciphertext)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/Nip44DecryptQuery.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/Nip44DecryptQuery.kt new file mode 100644 index 0000000000..4d9288f74c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/Nip44DecryptQuery.kt @@ -0,0 +1,54 @@ +/** + * 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.quartz.nip55AndroidSigner.api.background.queries + +import android.content.ContentResolver +import androidx.core.net.toUri +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType +import com.vitorpamplona.quartz.nip55AndroidSigner.api.DecryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.getStringByName +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.query + +class Nip44DecryptQuery( + val loggedInUser: HexKey, + val packageName: String, + val contentResolver: ContentResolver, +) { + val uri = "content://$packageName.${CommandType.NIP44_DECRYPT}".toUri() + + fun query( + ciphertext: String, + fromPubKey: HexKey, + ): SignerResult = + contentResolver.query( + uri, + arrayOf(ciphertext, fromPubKey, loggedInUser), + ) { cursor -> + val plaintext = cursor.getStringByName("result") + if (!plaintext.isNullOrBlank()) { + SignerResult.RequestAddressed.Successful(DecryptionResult(plaintext)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/Nip44EncryptQuery.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/Nip44EncryptQuery.kt new file mode 100644 index 0000000000..6e074344f5 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/Nip44EncryptQuery.kt @@ -0,0 +1,54 @@ +/** + * 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.quartz.nip55AndroidSigner.api.background.queries + +import android.content.ContentResolver +import androidx.core.net.toUri +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType +import com.vitorpamplona.quartz.nip55AndroidSigner.api.EncryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.getStringByName +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.query + +class Nip44EncryptQuery( + val loggedInUser: HexKey, + val packageName: String, + val contentResolver: ContentResolver, +) { + val uri = "content://$packageName.${CommandType.NIP44_ENCRYPT}".toUri() + + fun query( + plaintext: String, + toPubKey: HexKey, + ): SignerResult = + contentResolver.query( + uri, + arrayOf(plaintext, toPubKey, loggedInUser), + ) { cursor -> + val ciphertext = cursor.getStringByName("result") + if (!ciphertext.isNullOrBlank()) { + SignerResult.RequestAddressed.Successful(EncryptionResult(ciphertext)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/SignQuery.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/SignQuery.kt new file mode 100644 index 0000000000..bf1bc9924b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/SignQuery.kt @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries + +import android.content.ContentResolver +import androidx.core.net.toUri +import com.vitorpamplona.quartz.EventFactory +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.verify +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.getStringByName +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.query + +class SignQuery( + val loggedInUser: HexKey, + val packageName: String, + val contentResolver: ContentResolver, +) { + val uri = "content://$packageName.${CommandType.SIGN_EVENT}".toUri() + + fun query(unsignedEvent: Event): SignerResult = + contentResolver.query( + uri, + arrayOf(unsignedEvent.toJson(), unsignedEvent.pubKey, loggedInUser), + ) { cursor -> + val eventJson = cursor.getStringByName("event") + if (!eventJson.isNullOrBlank()) { + if (eventJson.startsWith("{")) { + val event = Event.fromJsonOrNull(eventJson) + if (event != null) { + if (event.verify()) { + SignerResult.RequestAddressed.Successful(SignResult(event)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotVerifyResultingEvent(event) + } + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotParseEventFromResult(eventJson) + } + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotParseEventFromResult(eventJson) + } + } else { + val signature = cursor.getStringByName("result") + if (!signature.isNullOrBlank()) { + val event: Event = + EventFactory.create( + id = unsignedEvent.id, + pubKey = unsignedEvent.pubKey, + createdAt = unsignedEvent.createdAt, + kind = unsignedEvent.kind, + tags = unsignedEvent.tags, + content = unsignedEvent.content, + sig = signature, + ) + if (event.verify()) { + SignerResult.RequestAddressed.Successful(SignResult(event)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotVerifyResultingEvent(event) + } + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/utils/ContentResolverExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/utils/ContentResolverExt.kt new file mode 100644 index 0000000000..ae940905b4 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/utils/ContentResolverExt.kt @@ -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.quartz.nip55AndroidSigner.api.background.utils + +import android.content.ContentResolver +import android.database.Cursor +import android.net.Uri +import android.util.Log +import com.vitorpamplona.quartz.nip55AndroidSigner.api.IResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult + +fun ContentResolver.query( + uri: Uri, + projection: Array, + map: (cursor: Cursor) -> SignerResult, +): SignerResult = + try { + query( + uri, + projection, + null, + null, + null, + ).use { + if (it != null && it.moveToFirst()) { + if (it.getColumnIndex("rejected") > -1) { + SignerResult.RequestAddressed.AutomaticallyRejected() + } else { + map(it) + } + } else { + SignerResult.RequestIncomplete.RequiresManualApproval() + } + } + } catch (e: Exception) { + Log.e("ExternalSignerLauncher", "Failed to query the Signer app in the background", e) + SignerResult.RequestIncomplete.ErrorExceptionCallingContentResolver(e) + } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/utils/CursorExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/utils/CursorExt.kt new file mode 100644 index 0000000000..ca81cc000f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/utils/CursorExt.kt @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils + +import android.database.Cursor + +fun Cursor.getStringByName(name: String): String? { + val index = getColumnIndex(name) + return if (index >= 0) { + val result = getString(index) + result.ifBlank { null } + } else { + null + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/IntentRequestManager.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/IntentRequestManager.kt new file mode 100644 index 0000000000..8fc522961b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/IntentRequestManager.kt @@ -0,0 +1,152 @@ +/** + * 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.quartz.nip55AndroidSigner.api.foreground + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.util.Log +import androidx.collection.LruCache +import com.vitorpamplona.quartz.nip55AndroidSigner.api.IResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResult +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.tryAndWait +import kotlin.collections.forEach +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume + +/** + * This class manages the lifecycle of foreground signing requests in a NIP-55 compliant Android signer flow. + * + * - It tracks pending signing requests using a unique ID and allows for awaiting their results via coroutines. + * - Provides a way to launch foreground Intents (to request user approval) and wait for the response. + * - Handles timeouts on user approval using [tryAndWait]. + * - Collects results via [newResponse] when the user responds to the foreground request. + * + * Main components: + * + * - `awaitingRequests`: LRU cache mapping request IDs to continuations for async response handling. + * - `appLauncher`: Function reference to launch foreground Intents (typically provided by an Activity). + * - `launchAndWait`: Suspend function to send an Intent, wait for an answer, and parse the result. + * - `newResponse`: Handles incoming results from the foreground activity using a unique ID. + * + * Key usage flows: + * + * - Request initiated -> store continuation by ID -> launch intent -> wait + * - User responds -> resume continuation -> remove ID -> return parsed result + * + * The class also cleans up pending requests on timeout or cancellation. + */ +class IntentRequestManager( + val foregroundApprovalTimeout: Long = 30000, +) { + val activityNotFoundIntent = Intent() + + // LRU cache to store pending requests and their continuations. + private val awaitingRequests = LruCache>(2000) + + // Function to launch an Intent in the foreground. + private var appLauncher: ((Intent) -> Unit)? = null + + /** Call this function when the launcher becomes available on activity, fragment or compose */ + fun registerForegroundLauncher(launcher: ((Intent) -> Unit)) { + this.appLauncher = launcher + } + + /** Call this function when the activity is destroyed or is about to be replaced. */ + fun unregisterForegroundLauncher(launcher: ((Intent) -> Unit)) { + if (this.appLauncher == launcher) { + this.appLauncher = null + } + } + + fun newResponse(data: Intent) { + val results = data.getStringExtra("results") + if (results != null) { + // This happens when the intent responds to many requests at the same time. + IntentResult.fromJsonArray(results).forEach { result -> + if (result.id != null) { + awaitingRequests[result.id]?.resume(result) + awaitingRequests.remove(result.id) + } + } + } else { + val result = IntentResult.fromIntent(data) + if (result.id != null) { + awaitingRequests[result.id]?.resume(result) + awaitingRequests.remove(result.id) + } + } + } + + fun hasForegroundActivity() = appLauncher != null + + /** + * Launches the signer, waits and parses the result + * + * @param requestIntent The Intent to be launched. + * @param parser A function that parses the response Intent into a [SignerResult.RequestAddressed]. + * @return The result after parsing the Intent using the provided parser. + * + * This function uses the [tryAndWait] utility to implement a timeout on the foreground approval. + * It assigns a unique ID to the request and keeps a continuation to resume once the result is received. + * If the timeout occurs or the continuation is cancelled, the request ID is cleaned up from [awaitingRequests]. + * Flags are added to the Intent to ensure it is brought to the front if already running. + */ + suspend fun launchWaitAndParse( + requestIntentBuilder: () -> Intent, + parser: (intent: IntentResult) -> SignerResult.RequestAddressed, + ): SignerResult.RequestAddressed = + appLauncher?.let { launcher -> + val requestIntent = requestIntentBuilder() + val callId = RandomInstance.randomChars(32) + + requestIntent.putExtra("id", callId) + requestIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) + + try { + val resultIntent = + tryAndWait(foregroundApprovalTimeout) { continuation -> + continuation.invokeOnCancellation { + awaitingRequests.remove(callId) + } + + awaitingRequests.put(callId, continuation) + + try { + launcher.invoke(requestIntent) + } catch (e: Exception) { + Log.e("ExternalSigner", "Error launching intent", e) + awaitingRequests.remove(callId) + throw e + } + } + + when (resultIntent) { + null -> SignerResult.RequestAddressed.TimedOut() + else -> parser(resultIntent) + } + } catch (e: ActivityNotFoundException) { + Log.e("ExternalSigner", "Error launching intent: Signer not found", e) + SignerResult.RequestAddressed.SignerNotFound() + } + } ?: SignerResult.RequestAddressed.NoActivityToLaunchFrom() +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/DecryptZapRequest.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/DecryptZapRequest.kt new file mode 100644 index 0000000000..80ee7602be --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/DecryptZapRequest.kt @@ -0,0 +1,43 @@ +/** + * 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.quartz.nip55AndroidSigner.api.foreground.intents.requests + +import android.content.Intent +import androidx.core.net.toUri +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent + +class DecryptZapRequest { + companion object { + fun assemble( + event: LnZapRequestEvent, + loggedInUser: HexKey, + packageName: String, + ): Intent { + val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:${event.toJson()}".toUri()) + intent.`package` = packageName + intent.putExtra("type", CommandType.NIP44_DECRYPT.code) + intent.putExtra("current_user", loggedInUser) + return intent + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/DeriveKeyRequest.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/DeriveKeyRequest.kt new file mode 100644 index 0000000000..dac0401635 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/DeriveKeyRequest.kt @@ -0,0 +1,42 @@ +/** + * 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.quartz.nip55AndroidSigner.api.foreground.intents.requests + +import android.content.Intent +import androidx.core.net.toUri +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType + +class DeriveKeyRequest { + companion object { + fun assemble( + nonce: HexKey, + loggedInUser: HexKey, + packageName: String, + ): Intent { + val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$nonce".toUri()) + intent.`package` = packageName + intent.putExtra("type", CommandType.DERIVE_KEY.code) + intent.putExtra("current_user", loggedInUser) + return intent + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/LoginRequest.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/LoginRequest.kt new file mode 100644 index 0000000000..39a02fa9c1 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/LoginRequest.kt @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests + +import android.content.Intent +import androidx.core.net.toUri +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType +import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.Permission + +class LoginRequest { + companion object { + val DefaultPermissions = + listOf( + Permission(CommandType.SIGN_EVENT, RelayAuthEvent.KIND), + Permission(CommandType.NIP04_ENCRYPT), + Permission(CommandType.NIP04_DECRYPT), + Permission(CommandType.NIP44_DECRYPT), + Permission(CommandType.NIP44_DECRYPT), + Permission(CommandType.DECRYPT_ZAP_EVENT), + ) + + fun assemble(permissions: List = DefaultPermissions): Intent { + val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:".toUri()) + intent.putExtra("type", CommandType.GET_PUBLIC_KEY.code) + intent.putExtra("permissions", JsonMapper.mapper.writeValueAsString(permissions)) + return intent + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/Nip04DecryptRequest.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/Nip04DecryptRequest.kt new file mode 100644 index 0000000000..a2e02e27f8 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/Nip04DecryptRequest.kt @@ -0,0 +1,44 @@ +/** + * 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.quartz.nip55AndroidSigner.api.foreground.intents.requests + +import android.content.Intent +import androidx.core.net.toUri +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType + +class Nip04DecryptRequest { + companion object { + fun assemble( + ciphertext: String, + fromPubKey: HexKey, + loggedInUser: HexKey, + packageName: String, + ): Intent { + val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$ciphertext".toUri()) + intent.`package` = packageName + intent.putExtra("type", CommandType.NIP04_DECRYPT.code) + intent.putExtra("pubKey", fromPubKey) + intent.putExtra("current_user", loggedInUser) + return intent + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/Nip04EncryptRequest.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/Nip04EncryptRequest.kt new file mode 100644 index 0000000000..473cbf5f4b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/Nip04EncryptRequest.kt @@ -0,0 +1,44 @@ +/** + * 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.quartz.nip55AndroidSigner.api.foreground.intents.requests + +import android.content.Intent +import androidx.core.net.toUri +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType + +class Nip04EncryptRequest { + companion object { + fun assemble( + plaintext: String, + toPubKey: HexKey, + loggedInUser: HexKey, + packageName: String, + ): Intent { + val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$plaintext".toUri()) + intent.`package` = packageName + intent.putExtra("type", CommandType.NIP04_ENCRYPT.code) + intent.putExtra("pubKey", toPubKey) + intent.putExtra("current_user", loggedInUser) + return intent + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/Nip44DecryptRequest.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/Nip44DecryptRequest.kt new file mode 100644 index 0000000000..0cd72d53a1 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/Nip44DecryptRequest.kt @@ -0,0 +1,44 @@ +/** + * 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.quartz.nip55AndroidSigner.api.foreground.intents.requests + +import android.content.Intent +import androidx.core.net.toUri +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType + +class Nip44DecryptRequest { + companion object { + fun assemble( + ciphertext: String, + fromPubKey: HexKey, + loggedInUser: HexKey, + packageName: String, + ): Intent { + val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$ciphertext".toUri()) + intent.`package` = packageName + intent.putExtra("type", CommandType.NIP44_DECRYPT.code) + intent.putExtra("pubKey", fromPubKey) + intent.putExtra("current_user", loggedInUser) + return intent + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/Nip44EncryptRequest.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/Nip44EncryptRequest.kt new file mode 100644 index 0000000000..4e19ffcb2f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/Nip44EncryptRequest.kt @@ -0,0 +1,44 @@ +/** + * 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.quartz.nip55AndroidSigner.api.foreground.intents.requests + +import android.content.Intent +import androidx.core.net.toUri +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType + +class Nip44EncryptRequest { + companion object { + fun assemble( + plaintext: String, + toPubKey: HexKey, + loggedInUser: HexKey, + packageName: String, + ): Intent { + val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$plaintext".toUri()) + intent.`package` = packageName + intent.putExtra("type", CommandType.NIP44_ENCRYPT.code) + intent.putExtra("pubKey", toPubKey) + intent.putExtra("current_user", loggedInUser) + return intent + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/SignRequest.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/SignRequest.kt new file mode 100644 index 0000000000..55136388dc --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/SignRequest.kt @@ -0,0 +1,43 @@ +/** + * 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.quartz.nip55AndroidSigner.api.foreground.intents.requests + +import android.content.Intent +import androidx.core.net.toUri +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType + +class SignRequest { + companion object { + fun assemble( + event: Event, + loggedInUser: HexKey, + packageName: String, + ): Intent { + val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:${event.toJson()}".toUri()) + intent.`package` = packageName + intent.putExtra("type", CommandType.SIGN_EVENT.code) + intent.putExtra("current_user", loggedInUser) + return intent + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/DecryptZapResponse.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/DecryptZapResponse.kt new file mode 100644 index 0000000000..35b3474a2c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/DecryptZapResponse.kt @@ -0,0 +1,54 @@ +/** + * 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.quartz.nip55AndroidSigner.api.foreground.intents.responses + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.ZapEventDecryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResult +import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent + +class DecryptZapResponse { + companion object { + fun assemble(event: LnZapPrivateEvent): IntentResult = + IntentResult( + result = event.toJson(), + ) + + fun parse(intent: IntentResult): SignerResult.RequestAddressed { + val eventJson = intent.result + return if (!eventJson.isNullOrBlank()) { + if (eventJson.startsWith("{")) { + val event = Event.fromJsonOrNull(eventJson) as? LnZapPrivateEvent + if (event != null) { + SignerResult.RequestAddressed.Successful(ZapEventDecryptionResult(event)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotParseEventFromResult(eventJson) + } + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/DeriveKeyResponse.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/DeriveKeyResponse.kt new file mode 100644 index 0000000000..3d51fb9a5c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/DeriveKeyResponse.kt @@ -0,0 +1,44 @@ +/** + * 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.quartz.nip55AndroidSigner.api.foreground.intents.responses + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.DerivationResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResult + +class DeriveKeyResponse { + companion object { + fun assemble(newPrivateKey: HexKey): IntentResult = + IntentResult( + result = newPrivateKey, + ) + + fun parse(intent: IntentResult): SignerResult.RequestAddressed { + val newPrivateKey = intent.result + return if (newPrivateKey != null) { + SignerResult.RequestAddressed.Successful(DerivationResult(newPrivateKey)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/LoginResponse.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/LoginResponse.kt new file mode 100644 index 0000000000..ce52787213 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/LoginResponse.kt @@ -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.quartz.nip55AndroidSigner.api.foreground.intents.responses + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.PubKeyResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResult + +class LoginResponse { + companion object { + fun assemble( + pubkey: HexKey, + packageName: String, + ): IntentResult = + IntentResult( + result = pubkey, + `package` = packageName, + ) + + fun parse(intent: IntentResult): SignerResult.RequestAddressed { + val pubkey = intent.result + val packageName = intent.`package` + + return if (pubkey != null && packageName != null) { + SignerResult.RequestAddressed.Successful( + PubKeyResult( + pubkey = pubkey, + packageName = packageName, + ), + ) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip04DecryptResponse.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip04DecryptResponse.kt new file mode 100644 index 0000000000..84cc20ab66 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip04DecryptResponse.kt @@ -0,0 +1,43 @@ +/** + * 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.quartz.nip55AndroidSigner.api.foreground.intents.responses + +import com.vitorpamplona.quartz.nip55AndroidSigner.api.DecryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResult + +class Nip04DecryptResponse { + companion object { + fun assemble(plaintext: String): IntentResult = + IntentResult( + result = plaintext, + ) + + fun parse(intent: IntentResult): SignerResult.RequestAddressed { + val plaintext = intent.result + return if (plaintext != null) { + SignerResult.RequestAddressed.Successful(DecryptionResult(plaintext)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip04EncryptResponse.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip04EncryptResponse.kt new file mode 100644 index 0000000000..969520fef0 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip04EncryptResponse.kt @@ -0,0 +1,43 @@ +/** + * 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.quartz.nip55AndroidSigner.api.foreground.intents.responses + +import com.vitorpamplona.quartz.nip55AndroidSigner.api.EncryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResult + +class Nip04EncryptResponse { + companion object { + fun assemble(ciphertext: String): IntentResult = + IntentResult( + result = ciphertext, + ) + + fun parse(intent: IntentResult): SignerResult.RequestAddressed { + val ciphertext = intent.result + return if (ciphertext != null) { + SignerResult.RequestAddressed.Successful(EncryptionResult(ciphertext)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip44DecryptResponse.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip44DecryptResponse.kt new file mode 100644 index 0000000000..e65dd8f4f3 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip44DecryptResponse.kt @@ -0,0 +1,43 @@ +/** + * 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.quartz.nip55AndroidSigner.api.foreground.intents.responses + +import com.vitorpamplona.quartz.nip55AndroidSigner.api.DecryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResult + +class Nip44DecryptResponse { + companion object { + fun assemble(plaintext: String): IntentResult = + IntentResult( + result = plaintext, + ) + + fun parse(intent: IntentResult): SignerResult.RequestAddressed { + val plaintext = intent.result + return if (plaintext != null) { + SignerResult.RequestAddressed.Successful(DecryptionResult(plaintext)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip44EncryptResponse.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip44EncryptResponse.kt new file mode 100644 index 0000000000..243856a389 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/Nip44EncryptResponse.kt @@ -0,0 +1,43 @@ +/** + * 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.quartz.nip55AndroidSigner.api.foreground.intents.responses + +import com.vitorpamplona.quartz.nip55AndroidSigner.api.EncryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResult + +class Nip44EncryptResponse { + companion object { + fun assemble(ciphertext: String): IntentResult = + IntentResult( + result = ciphertext, + ) + + fun parse(intent: IntentResult): SignerResult.RequestAddressed { + val ciphertext = intent.result + return if (ciphertext != null) { + SignerResult.RequestAddressed.Successful(EncryptionResult(ciphertext)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/SignResponse.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/SignResponse.kt new file mode 100644 index 0000000000..6e8a6d639e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/SignResponse.kt @@ -0,0 +1,82 @@ +/** + * 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.quartz.nip55AndroidSigner.api.foreground.intents.responses + +import com.vitorpamplona.quartz.EventFactory +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.verify +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResult + +class SignResponse { + companion object { + fun assemble(event: Event): IntentResult = + IntentResult( + event = event.toJson(), + result = event.sig, + ) + + fun parse( + intent: IntentResult, + unsignedEvent: Event, + ): SignerResult.RequestAddressed { + val eventJson = intent.event + return if (eventJson != null) { + if (eventJson.startsWith("{")) { + val event = Event.fromJsonOrNull(eventJson) + if (event != null) { + if (event.verify()) { + SignerResult.RequestAddressed.Successful(SignResult(event)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotVerifyResultingEvent(event) + } + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotParseEventFromResult(eventJson) + } + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform(eventJson) + } + } else { + val signature = intent.result + if (signature != null && signature.length == 128) { + val event: Event = + EventFactory.create( + id = unsignedEvent.id, + pubKey = unsignedEvent.pubKey, + createdAt = unsignedEvent.createdAt, + kind = unsignedEvent.kind, + tags = unsignedEvent.tags, + content = unsignedEvent.content, + sig = signature, + ) + if (event.verify()) { + SignerResult.RequestAddressed.Successful(SignResult(event)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotVerifyResultingEvent(event) + } + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform(signature) + } + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResult.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResult.kt new file mode 100644 index 0000000000..b1beca7a87 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResult.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results + +import android.content.Intent +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper + +data class IntentResult( + val `package`: String? = null, + val result: String? = null, + val event: String? = null, + val id: String? = null, +) { + fun toJson(): String = JsonMapper.mapper.writeValueAsString(this) + + fun toIntent(): Intent { + val intent = Intent() + intent.putExtra("id", id) + intent.putExtra("result", result) + intent.putExtra("event", event) + intent.putExtra("package", `package`) + return intent + } + + companion object { + fun fromIntent(data: Intent): IntentResult = + IntentResult( + id = data.getStringExtra("id"), + result = data.getStringExtra("result"), + event = data.getStringExtra("event"), + `package` = data.getStringExtra("package"), + ) + + fun fromJson(json: String): IntentResult = JsonMapper.mapper.readValue(json) + + fun fromJsonArray(json: String): List = JsonMapper.mapper.readValue>(json) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResultJsonDeserializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResultJsonDeserializer.kt new file mode 100644 index 0000000000..e53cb8be22 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResultJsonDeserializer.kt @@ -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.quartz.nip55AndroidSigner.api.foreground.intents.results + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.deser.std.StdDeserializer + +class IntentResultJsonDeserializer : StdDeserializer(IntentResult::class.java) { + override fun deserialize( + jp: JsonParser, + ctxt: DeserializationContext, + ): IntentResult { + val jsonObject: JsonNode = jp.codec.readTree(jp) + return IntentResult( + `package` = jsonObject.get("package")?.asText()?.intern(), + result = jsonObject.get("result")?.asText()?.intern(), + event = jsonObject.get("event")?.asText()?.intern(), + id = jsonObject.get("id")?.asText()?.intern(), + ) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResultJsonSerializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResultJsonSerializer.kt new file mode 100644 index 0000000000..289f4daed0 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResultJsonSerializer.kt @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.ser.std.StdSerializer + +class IntentResultJsonSerializer : StdSerializer(IntentResult::class.java) { + override fun serialize( + result: IntentResult, + gen: JsonGenerator, + provider: SerializerProvider, + ) { + gen.writeStartObject() + result.`package`?.let { gen.writeStringField("package", it) } + result.result?.let { gen.writeStringField("result", it) } + result.event?.let { gen.writeStringField("event", it) } + result.id?.let { gen.writeStringField("id", it) } + gen.writeEndObject() + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/permission/Permission.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/permission/Permission.kt new file mode 100644 index 0000000000..9c1511b532 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/permission/Permission.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.permission + +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType + +class Permission( + val type: CommandType?, + val kind: Int? = null, +) { + fun toJson(): String = JsonMapper.mapper.writeValueAsString(this) + + companion object { + fun fromJson(json: String): Permission = JsonMapper.mapper.readValue(json, Permission::class.java) + + fun fromJsonArray(json: String): Array = JsonMapper.mapper.readValue(json, Array::class.java) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/permission/PermissionDeserializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/permission/PermissionDeserializer.kt new file mode 100644 index 0000000000..ebea3de4b1 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/permission/PermissionDeserializer.kt @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.permission + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.deser.std.StdDeserializer +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType + +class PermissionDeserializer : StdDeserializer(Permission::class.java) { + override fun deserialize( + jp: JsonParser, + ctxt: DeserializationContext, + ): Permission { + val jsonObject: JsonNode = jp.codec.readTree(jp) + return Permission( + type = CommandType.parse(jsonObject.get("type").asText()), + kind = jsonObject.get("kind")?.asInt(), + ) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/permission/PermissionSerializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/permission/PermissionSerializer.kt new file mode 100644 index 0000000000..3d53505314 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/api/permission/PermissionSerializer.kt @@ -0,0 +1,42 @@ +/** + * 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.quartz.nip55AndroidSigner.api.permission + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.ser.std.StdSerializer + +class PermissionSerializer : StdSerializer(Permission::class.java) { + override fun serialize( + permission: Permission, + gen: JsonGenerator, + provider: SerializerProvider, + ) { + gen.writeStartObject() + permission.type.let { gen.writeStringField("type", it?.code) } + if (permission.kind != null) { + gen.writeNumberField("kind", permission.kind) + } else { + gen.writeNullField("kind") + } + gen.writeEndObject() + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/ExternalSignerLogin.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/ExternalSignerLogin.kt new file mode 100644 index 0000000000..015c3fc65f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/ExternalSignerLogin.kt @@ -0,0 +1,42 @@ +/** + * 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.quartz.nip55AndroidSigner.client + +import android.content.Intent +import com.vitorpamplona.quartz.nip55AndroidSigner.api.PubKeyResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.LoginRequest +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.LoginResponse +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.Permission + +object ExternalSignerLogin { + fun createIntent(permissions: List = LoginRequest.DefaultPermissions): Intent { + val intent = LoginRequest.assemble(permissions) + intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) + return intent + } + + fun parseResult(data: Intent): SignerResult { + val result = IntentResult.fromIntent(data) + return LoginResponse.parse(result) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/IActivityLauncher.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/IActivityLauncher.kt new file mode 100644 index 0000000000..5ac3350a8e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/IActivityLauncher.kt @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.client + +import android.content.Intent + +interface IActivityLauncher { + fun registerForegroundLauncher(launcher: ((Intent) -> Unit)) + + fun unregisterForegroundLauncher(launcher: ((Intent) -> Unit)) + + fun newResponse(data: Intent) + + fun hasForegroundActivity(): Boolean +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/IsExternalSignerInstalled.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/IsExternalSignerInstalled.kt new file mode 100644 index 0000000000..92d7f5e48c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/IsExternalSignerInstalled.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.client + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import androidx.core.net.toUri + +@SuppressLint("QueryPermissionsNeeded") +fun isExternalSignerInstalled(context: Context): Boolean = + context.packageManager + .queryIntentActivities( + Intent().apply { + action = Intent.ACTION_VIEW + data = "nostrsigner:".toUri() + }, + 0, + ).isNotEmpty() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/NostrSignerExternal.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/NostrSignerExternal.kt new file mode 100644 index 0000000000..40cf1749c4 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/NostrSignerExternal.kt @@ -0,0 +1,194 @@ +/** + * 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.quartz.nip55AndroidSigner.client + +import android.content.ContentResolver +import android.content.Intent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip55AndroidSigner.api.DecryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.DerivationResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.EncryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.ZapEventDecryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.client.handlers.BackgroundRequestHandler +import com.vitorpamplona.quartz.nip55AndroidSigner.client.handlers.ForegroundRequestHandler +import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import java.lang.Exception + +class NostrSignerExternal( + pubKey: HexKey, + packageName: String, + contentResolver: ContentResolver, +) : NostrSigner(pubKey), + IActivityLauncher { + override fun isWriteable(): Boolean = true + + val backgroundQuery = BackgroundRequestHandler(pubKey, packageName, contentResolver) + val foregroundQuery = ForegroundRequestHandler(pubKey, packageName) + + override fun registerForegroundLauncher(launcher: ((Intent) -> Unit)) { + this.foregroundQuery.launcher.registerForegroundLauncher(launcher) + } + + override fun unregisterForegroundLauncher(launcher: ((Intent) -> Unit)) { + this.foregroundQuery.launcher.unregisterForegroundLauncher(launcher) + } + + override fun newResponse(data: Intent) { + this.foregroundQuery.launcher.newResponse(data) + } + + override fun hasForegroundActivity() = this.foregroundQuery.launcher.hasForegroundActivity() + + override suspend fun sign( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): T { + val unsignedEvent = + Event( + id = EventHasher.hashId(pubKey, createdAt, kind, tags, content), + pubKey = pubKey, + createdAt = createdAt, + kind = kind, + tags = tags, + content = content, + sig = "", + ) + + val result = backgroundQuery.sign(unsignedEvent) ?: foregroundQuery.sign(unsignedEvent) + + if (result is SignerResult.RequestAddressed.Successful) { + (result.result.event as? T)?.let { + return it + } + } + + throw convertExceptions("Could not sign", result) + } + + override suspend fun nip04Encrypt( + plaintext: String, + toPublicKey: HexKey, + ): String { + if (plaintext.isBlank()) return "" + + val result = backgroundQuery.nip04Encrypt(plaintext, toPublicKey) ?: foregroundQuery.nip04Encrypt(plaintext, toPublicKey) + + if (result is SignerResult.RequestAddressed.Successful) { + return result.result.ciphertext + } + + throw convertExceptions("Could not encrypt", result) + } + + override suspend fun nip04Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ): String { + if (ciphertext.isBlank()) throw SignerExceptions.NothingToDecrypt() + + val result = backgroundQuery.nip04Decrypt(ciphertext, fromPublicKey) ?: foregroundQuery.nip04Decrypt(ciphertext, fromPublicKey) + + if (result is SignerResult.RequestAddressed.Successful) { + return result.result.plaintext + } + + throw convertExceptions("Could not decrypt", result) + } + + override suspend fun nip44Encrypt( + plaintext: String, + toPublicKey: HexKey, + ): String { + if (plaintext.isBlank()) return "" + + val result = backgroundQuery.nip44Encrypt(plaintext, toPublicKey) ?: foregroundQuery.nip44Encrypt(plaintext, toPublicKey) + + if (result is SignerResult.RequestAddressed.Successful) { + return result.result.ciphertext + } + + throw convertExceptions("Could not encrypt", result) + } + + override suspend fun nip44Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ): String { + if (ciphertext.isBlank()) throw SignerExceptions.NothingToDecrypt() + + val result = backgroundQuery.nip44Decrypt(ciphertext, fromPublicKey) ?: foregroundQuery.nip44Decrypt(ciphertext, fromPublicKey) + + if (result is SignerResult.RequestAddressed.Successful) { + return result.result.plaintext + } + + throw convertExceptions("Could not decrypt", result) + } + + override suspend fun deriveKey(nonce: HexKey): HexKey { + val result = backgroundQuery.deriveKey(nonce) ?: foregroundQuery.deriveKey(nonce) + + if (result is SignerResult.RequestAddressed.Successful) { + return result.result.newPrivKey + } + + throw convertExceptions("Could not derive key", result) + } + + override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent { + if (!event.isPrivateZap()) throw SignerExceptions.NothingToDecrypt() + + val result = backgroundQuery.decryptZapEvent(event) ?: foregroundQuery.decryptZapEvent(event) + + if (result is SignerResult.RequestAddressed.Successful) { + return result.result.privateEvent + } + + throw convertExceptions("Could not decrypt private zap", result) + } + + fun convertExceptions( + title: String, + result: SignerResult.RequestAddressed<*>, + ): Exception = + when (result) { + is SignerResult.RequestAddressed.Successful<*> -> IllegalStateException("$title: This should not happen. There is a bug on Quartz.") + is SignerResult.RequestAddressed.ReceivedButCouldNotParseEventFromResult<*> -> IllegalStateException("$title: Failed to parse event: ${result.eventJson}.") + is SignerResult.RequestAddressed.ReceivedButCouldNotVerifyResultingEvent<*> -> IllegalStateException("$title: Failed to verify event: ${result.invalidEvent.toJson()}.") + + is SignerResult.RequestAddressed.ReceivedButCouldNotPerform<*> -> SignerExceptions.CouldNotPerformException("$title: ${result.message}") + is SignerResult.RequestAddressed.SignerNotFound<*> -> SignerExceptions.SignerNotFoundException("$title: Signer app was not found.") + + is SignerResult.RequestAddressed.AutomaticallyRejected<*> -> SignerExceptions.AutomaticallyUnauthorizedException("$title: User has rejected the request.") + is SignerResult.RequestAddressed.ManuallyRejected<*> -> SignerExceptions.ManuallyUnauthorizedException("$title: User has rejected the request.") + is SignerResult.RequestAddressed.TimedOut<*> -> SignerExceptions.TimedOutException("$title: User didn't accept or reject in time.") + is SignerResult.RequestAddressed.NoActivityToLaunchFrom<*> -> SignerExceptions.RunningOnBackgroundWithoutAutomaticPermissionException("$title: No activity to launch from.") + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/BackgroundRequestHandler.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/BackgroundRequestHandler.kt new file mode 100644 index 0000000000..fcf072db51 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/BackgroundRequestHandler.kt @@ -0,0 +1,84 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.client.handlers + +import android.content.ContentResolver +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.DecryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.DerivationResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.EncryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.PubKeyResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.ZapEventDecryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.DecryptZapQuery +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.DeriveKeyQuery +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.LoginQuery +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.Nip04DecryptQuery +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.Nip04EncryptQuery +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.Nip44DecryptQuery +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.Nip44EncryptQuery +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.SignQuery +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent + +class BackgroundRequestHandler( + loggedInUser: HexKey, + packageName: String, + contentResolver: ContentResolver, +) { + val login = LoginQuery(packageName, contentResolver) + val sign = SignQuery(loggedInUser, packageName, contentResolver) + val nip04Encrypt = Nip04EncryptQuery(loggedInUser, packageName, contentResolver) + val nip04Decrypt = Nip04DecryptQuery(loggedInUser, packageName, contentResolver) + val nip44Encrypt = Nip44EncryptQuery(loggedInUser, packageName, contentResolver) + val nip44Decrypt = Nip44DecryptQuery(loggedInUser, packageName, contentResolver) + val decryptZap = DecryptZapQuery(loggedInUser, packageName, contentResolver) + val deriveKey = DeriveKeyQuery(loggedInUser, packageName, contentResolver) + + fun login() = login.query() as? SignerResult.RequestAddressed + + fun sign(unsignedEvent: Event) = sign.query(unsignedEvent) as? SignerResult.RequestAddressed + + fun nip04Encrypt( + plaintext: String, + toPubKey: HexKey, + ) = nip04Encrypt.query(plaintext, toPubKey) as? SignerResult.RequestAddressed + + fun nip04Decrypt( + ciphertext: String, + fromPubKey: HexKey, + ) = nip04Decrypt.query(ciphertext, fromPubKey) as? SignerResult.RequestAddressed + + fun nip44Encrypt( + plaintext: String, + toPubKey: HexKey, + ) = nip44Encrypt.query(plaintext, toPubKey) as? SignerResult.RequestAddressed + + fun nip44Decrypt( + ciphertext: String, + fromPubKey: HexKey, + ) = nip44Decrypt.query(ciphertext, fromPubKey) as? SignerResult.RequestAddressed + + fun decryptZapEvent(event: LnZapRequestEvent) = decryptZap.query(event) as? SignerResult.RequestAddressed + + fun deriveKey(nonce: HexKey) = deriveKey.query(nonce) as? SignerResult.RequestAddressed +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/ForegroundRequestHandler.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/ForegroundRequestHandler.kt new file mode 100644 index 0000000000..c86f9ebcfc --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/ForegroundRequestHandler.kt @@ -0,0 +1,98 @@ +/** + * 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.quartz.nip55AndroidSigner.client.handlers + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.IntentRequestManager +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.DecryptZapRequest +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.DeriveKeyRequest +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.Nip04DecryptRequest +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.Nip04EncryptRequest +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.Nip44DecryptRequest +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.Nip44EncryptRequest +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.SignRequest +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.DecryptZapResponse +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.DeriveKeyResponse +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.Nip04DecryptResponse +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.Nip04EncryptResponse +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.Nip44DecryptResponse +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.Nip44EncryptResponse +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.SignResponse +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent + +class ForegroundRequestHandler( + val loggedInUser: HexKey, + val packageName: String, + foregroundApprovalTimeout: Long = 30000, +) { + val launcher = IntentRequestManager(foregroundApprovalTimeout) + + suspend fun sign(unsignedEvent: Event) = + launcher.launchWaitAndParse( + requestIntentBuilder = { SignRequest.assemble(unsignedEvent, loggedInUser, packageName) }, + parser = { intent -> SignResponse.parse(intent, unsignedEvent) }, + ) + + suspend fun nip04Encrypt( + plaintext: String, + toPubKey: HexKey, + ) = launcher.launchWaitAndParse( + requestIntentBuilder = { Nip04EncryptRequest.assemble(plaintext, toPubKey, loggedInUser, packageName) }, + parser = Nip04EncryptResponse::parse, + ) + + suspend fun nip04Decrypt( + ciphertext: String, + fromPubKey: HexKey, + ) = launcher.launchWaitAndParse( + requestIntentBuilder = { Nip04DecryptRequest.assemble(ciphertext, fromPubKey, loggedInUser, packageName) }, + parser = Nip04DecryptResponse::parse, + ) + + suspend fun nip44Encrypt( + plaintext: String, + toPubKey: HexKey, + ) = launcher.launchWaitAndParse( + requestIntentBuilder = { Nip44EncryptRequest.assemble(plaintext, toPubKey, loggedInUser, packageName) }, + parser = Nip44EncryptResponse::parse, + ) + + suspend fun nip44Decrypt( + ciphertext: String, + fromPubKey: HexKey, + ) = launcher.launchWaitAndParse( + requestIntentBuilder = { Nip44DecryptRequest.assemble(ciphertext, fromPubKey, loggedInUser, packageName) }, + parser = Nip44DecryptResponse::parse, + ) + + suspend fun decryptZapEvent(event: LnZapRequestEvent) = + launcher.launchWaitAndParse( + requestIntentBuilder = { DecryptZapRequest.assemble(event, loggedInUser, packageName) }, + parser = DecryptZapResponse::parse, + ) + + suspend fun deriveKey(nonce: HexKey) = + launcher.launchWaitAndParse( + requestIntentBuilder = { DeriveKeyRequest.assemble(nonce, loggedInUser, packageName) }, + parser = DeriveKeyResponse::parse, + ) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/ReportEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/ReportEvent.kt index 158d755574..9b6f5a3637 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/ReportEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/ReportEvent.kt @@ -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,9 +24,7 @@ import androidx.compose.runtime.Immutable 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.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate -import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip56Reports.tags.DefaultReportTag import com.vitorpamplona.quartz.nip56Reports.tags.ReportedAddressTag @@ -75,28 +73,6 @@ class ReportEvent( const val KIND = 1984 const val ALT_PREFIX = "Report for " - fun create( - reportedPost: Event, - type: ReportType, - signer: NostrSigner, - content: String = "", - createdAt: Long = TimeUtils.now(), - onReady: (ReportEvent) -> Unit, - ) { - val reportPostTag = arrayOf("e", reportedPost.id, type.name.lowercase()) - val reportAuthorTag = arrayOf("p", reportedPost.pubKey, type.name.lowercase()) - - var tags: Array> = arrayOf(reportPostTag, reportAuthorTag) - - if (reportedPost is AddressableEvent) { - tags += listOf(arrayOf("a", reportedPost.aTag().toTag())) - } - - tags += listOf(AltTag.assemble("Report for ${type.name}")) - - signer.sign(createdAt, KIND, tags, content, onReady) - } - fun build( reportedPost: Event, type: ReportType, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/ReportType.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/ReportType.kt index 6ff598773d..018b5e5221 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/ReportType.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/ReportType.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/TagArrayBuilderExt.kt index b663a3d134..55f621e878 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/tags/BaseReportTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/tags/BaseReportTag.kt index 0ccff5b2cc..8e7764819f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/tags/BaseReportTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/tags/BaseReportTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/tags/DefaultReportTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/tags/DefaultReportTag.kt index adc260a1ba..94516387e7 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/tags/DefaultReportTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/tags/DefaultReportTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/tags/HashSha256Tag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/tags/HashSha256Tag.kt index 172e94c132..9d95975062 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/tags/HashSha256Tag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/tags/HashSha256Tag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/tags/ReportedAddressTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/tags/ReportedAddressTag.kt index 8910a5fe20..a7bca9fe24 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/tags/ReportedAddressTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/tags/ReportedAddressTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/tags/ReportedAuthorTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/tags/ReportedAuthorTag.kt index a0d155dcd6..23d7eeb293 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/tags/ReportedAuthorTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/tags/ReportedAuthorTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/tags/ReportedEventTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/tags/ReportedEventTag.kt index a939f0c7d1..59fe9d0c0a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/tags/ReportedEventTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/tags/ReportedEventTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/tags/ServerTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/tags/ServerTag.kt index 9d42dc08da..d1124ebc3d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/tags/ServerTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/tags/ServerTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapEvent.kt index a8855e4b76..5393d2b4da 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapEvent.kt @@ -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,6 +26,12 @@ import com.vitorpamplona.quartz.experimental.zapPolls.tags.PollOptionTag import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.utils.pointerSizeInBytes @Immutable @@ -37,7 +43,22 @@ class LnZapEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), - LnZapEventInterface { + LnZapEventInterface, + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + // This event is also kept in LocalCache (same object) @Transient val zapRequest: LnZapRequestEvent? @@ -68,9 +89,9 @@ class LnZapEvent( zapRequest = containedPost() } - override fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } + override fun zappedPost() = tags.mapNotNull(ETag::parseId) - override fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } + override fun zappedAuthor() = tags.mapNotNull(PTag::parseKey) override fun zappedPollOption(): Int? = try { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapEventInterface.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapEventInterface.kt index 9510d1464b..41c25adead 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapEventInterface.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapEventInterface.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapPrivateEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapPrivateEvent.kt index 3b0aceb66a..f2901533ee 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapPrivateEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapPrivateEvent.kt @@ -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,8 +23,14 @@ package com.vitorpamplona.quartz.nip57Zaps import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -35,26 +41,38 @@ class LnZapPrivateEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + companion object { const val KIND = 9733 const val ALT = "Private zap" - fun create( + suspend fun create( signer: NostrSigner, tags: Array> = emptyArray(), content: String = "", createdAt: Long = TimeUtils.now(), - onReady: (LnZapPrivateEvent) -> Unit, - ) { - signer.sign(createdAt, KIND, tags, content, onReady) - } + ): LnZapPrivateEvent = signer.sign(createdAt, KIND, tags, content) fun create( signer: NostrSignerSync, tags: Array> = emptyArray(), content: String = "", createdAt: Long = TimeUtils.now(), - ): LnZapPrivateEvent? = signer.sign(createdAt, KIND, tags, content) + ): LnZapPrivateEvent = signer.sign(createdAt, KIND, tags, content) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt index b4e6cf173b..e2768c4590 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt @@ -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,14 +25,18 @@ import com.vitorpamplona.quartz.experimental.zapPolls.tags.PollOptionTag 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.core.hexToByteArray -import com.vitorpamplona.quartz.nip01Core.core.mapValues import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +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.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.utils.pointerSizeInBytes @Immutable class LnZapRequestEvent( @@ -42,54 +46,39 @@ class LnZapRequestEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - @Transient private var privateZapEvent: LnZapPrivateEvent? = null +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) - override fun countMemory(): Long = super.countMemory() + pointerSizeInBytes + (privateZapEvent?.countMemory() ?: 0) + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) - fun zappedPost() = tags.mapValues("e") + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) - fun zappedAuthor() = tags.mapValues("p") + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + + fun zappedPost() = tags.mapNotNull(ETag::parseId) + + fun zappedAuthor() = tags.mapNotNull(PTag::parseKey) fun isPrivateZap() = tags.any { t -> t.size >= 2 && t[0] == "anon" && t[1].isNotBlank() } - fun getPrivateZapEvent( - loggedInUserPrivKey: ByteArray, - pubKey: HexKey, - ): LnZapPrivateEvent? { + fun getAnonTag(): String { val anonTag = tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" } if (anonTag != null) { - val encnote = anonTag[1] - if (encnote.isNotBlank()) { - try { - val note = PrivateZapEncryption.decryptPrivateZapMessage(encnote, loggedInUserPrivKey, pubKey.hexToByteArray()) - val decryptedEvent = fromJson(note) - if (decryptedEvent.kind == 9733) { - return decryptedEvent as LnZapPrivateEvent - } - } catch (e: Exception) { - e.printStackTrace() - } + val encNote = anonTag[1] + if (encNote.isNotBlank()) { + return encNote + } else { + throw IllegalStateException("Anon tag is empty.") } - } - return null - } - - fun cachedPrivateZap(): LnZapPrivateEvent? = privateZapEvent - - fun decryptPrivateZap( - signer: NostrSigner, - onReady: (Event) -> Unit, - ) { - privateZapEvent?.let { - onReady(it) - return - } - - signer.decryptZapEvent(this) { - // caches it - privateZapEvent = it - onReady(it) + } else { + throw IllegalStateException("This is not a private zap.") } } @@ -97,69 +86,69 @@ class LnZapRequestEvent( const val KIND = 9734 const val ALT = "Zap request" - fun create( - originalNote: Event, - relays: Set, + suspend fun create( + zappedEvent: Event, + relays: Set, signer: NostrSigner, pollOption: Int?, message: String, zapType: LnZapEvent.ZapType, toUserPubHex: String?, createdAt: Long = TimeUtils.now(), - onReady: (LnZapRequestEvent) -> Unit, - ) { - if (zapType == LnZapEvent.ZapType.NONZAP) return - + ): LnZapRequestEvent { var tags = listOf( - arrayOf("e", originalNote.id), - arrayOf("p", toUserPubHex ?: originalNote.pubKey), - arrayOf("relays") + relays, + arrayOf("e", zappedEvent.id), + arrayOf("p", toUserPubHex ?: zappedEvent.pubKey), + arrayOf("relays") + relays.map { it.url }, AltTag.assemble(ALT), ) - if (originalNote is AddressableEvent) { - tags = tags + listOf(arrayOf("a", originalNote.aTag().toTag())) + if (zappedEvent is AddressableEvent) { + tags = tags + listOf(arrayOf("a", zappedEvent.aTag().toTag())) } if (pollOption != null && pollOption >= 0) { tags = tags + listOf(arrayOf(PollOptionTag.TAG_NAME, pollOption.toString())) } - if (zapType == LnZapEvent.ZapType.ANONYMOUS) { - tags = tags + listOf(arrayOf("anon")) - NostrSignerInternal(KeyPair()).sign(createdAt, KIND, tags.toTypedArray(), message, onReady) - } else if (zapType == LnZapEvent.ZapType.PRIVATE) { - tags = tags + listOf(arrayOf("anon", "")) - signer.sign(createdAt, KIND, tags.toTypedArray(), message, onReady) - } else { - signer.sign(createdAt, KIND, tags.toTypedArray(), message, onReady) + return when (zapType) { + LnZapEvent.ZapType.PUBLIC -> signer.sign(createdAt, KIND, tags.toTypedArray(), message) + LnZapEvent.ZapType.ANONYMOUS -> { + tags = tags + listOf(arrayOf("anon")) + NostrSignerInternal(KeyPair()).sign(createdAt, KIND, tags.toTypedArray(), message) + } + LnZapEvent.ZapType.PRIVATE -> { + tags = tags + listOf(arrayOf("anon", "")) + signer.sign(createdAt, KIND, tags.toTypedArray(), message) + } + LnZapEvent.ZapType.NONZAP -> throw IllegalArgumentException("Invalid zap type") } } - fun create( + suspend fun create( userHex: String, - relays: Set, + relays: Set, signer: NostrSigner, message: String, zapType: LnZapEvent.ZapType, createdAt: Long = TimeUtils.now(), - onReady: (LnZapRequestEvent) -> Unit, - ) { - if (zapType == LnZapEvent.ZapType.NONZAP) return - + ): LnZapRequestEvent { var tags = arrayOf( arrayOf("p", userHex), - arrayOf("relays") + relays, + arrayOf("relays") + relays.map { it.url }, ) - if (zapType == LnZapEvent.ZapType.ANONYMOUS) { - tags += arrayOf(arrayOf("anon", "")) - NostrSignerInternal(KeyPair()).sign(createdAt, KIND, tags, message, onReady) - } else if (zapType == LnZapEvent.ZapType.PRIVATE) { - tags += arrayOf(arrayOf("anon", "")) - signer.sign(createdAt, KIND, tags, message, onReady) - } else { - signer.sign(createdAt, KIND, tags, message, onReady) + return when (zapType) { + LnZapEvent.ZapType.PUBLIC -> signer.sign(createdAt, KIND, tags, message) + LnZapEvent.ZapType.ANONYMOUS -> { + tags += arrayOf(arrayOf("anon", "")) + NostrSignerInternal(KeyPair()).sign(createdAt, KIND, tags, message) + } + LnZapEvent.ZapType.PRIVATE -> { + tags += arrayOf(arrayOf("anon", "")) + signer.sign(createdAt, KIND, tags, message) + } + LnZapEvent.ZapType.NONZAP -> throw IllegalArgumentException("Invalid zap type") } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapCache.kt new file mode 100644 index 0000000000..9e8ede2061 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapCache.kt @@ -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.quartz.nip57Zaps + +import androidx.collection.LruCache +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.caches.DecryptCache + +class PrivateZapCache( + signer: NostrSigner, +) { + private val decryptionCache = + object : LruCache(1000) { + override fun create(key: LnZapRequestEvent): PrivateZapDecryptCache? { + val zappedAuthor = key.zappedAuthor().firstOrNull() + return if (key.isPrivateZap() && zappedAuthor != null) { + PrivateZapDecryptCache(signer) + } else { + null + } + } + } + + fun delete(event: LnZapRequestEvent) { + decryptionCache.remove(event) + } + + fun cachedPrivateZap(event: LnZapRequestEvent): LnZapPrivateEvent? = decryptionCache[event]?.cached() + + suspend fun decryptPrivateZap(event: LnZapRequestEvent) = decryptionCache[event]?.decrypt(event) +} + +class PrivateZapDecryptCache( + signer: NostrSigner, +) : DecryptCache(signer) { + override suspend fun decryptAndParse( + event: LnZapRequestEvent, + signer: NostrSigner, + ): LnZapPrivateEvent = signer.decryptZapEvent(event) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapEncryption.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapEncryption.kt index 1588feb579..f6704ccb65 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapEncryption.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapEncryption.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapRequestBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapRequestBuilder.kt index d71c73bc09..86d4ba75f4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapRequestBuilder.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/PrivateZapRequestBuilder.kt @@ -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,12 +20,14 @@ */ package com.vitorpamplona.quartz.nip57Zaps -import android.util.Log +import com.vitorpamplona.quartz.nip01Core.core.Event.Companion.fromJson +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.crypto.Nip01 import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions class PrivateZapRequestBuilder { fun signPrivateZapRequest( @@ -34,11 +36,13 @@ class PrivateZapRequestBuilder { tags: Array>, content: String, signer: NostrSignerSync, - ): T? { - if (signer.keyPair.privKey == null) return null + ): T { + if (signer.keyPair.privKey == null) throw SignerExceptions.ReadOnlyException() val zappedEvent = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.let { it[1] } - val userHex = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.let { it[1] } ?: return null + val userHex = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.let { it[1] } + + require(userHex != null) { "A user is required when creating private zaps" } // if it is a Zap for an Event, use event.id if not, use the user's pubkey val idToGeneratePrivateKey = zappedEvent ?: userHex @@ -52,7 +56,7 @@ class PrivateZapRequestBuilder { val fullTagsNoAnon = tags.filter { t -> t.getOrNull(0) != "anon" }.toTypedArray() - val privateEvent = LnZapPrivateEvent.create(signer, fullTagsNoAnon, content) ?: return null + val privateEvent = LnZapPrivateEvent.create(signer, fullTagsNoAnon, content) val noteJson = privateEvent.toJson() val encryptedContent = @@ -71,22 +75,19 @@ class PrivateZapRequestBuilder { fun decryptZapEvent( event: LnZapRequestEvent, signer: NostrSignerSync, - ): LnZapPrivateEvent? { - if (signer.keyPair.privKey == null) return null + ): LnZapPrivateEvent { + if (signer.keyPair.privKey == null) throw SignerExceptions.ReadOnlyException() val recipientPK = event.zappedAuthor().firstOrNull() val recipientPost = event.zappedPost().firstOrNull() val privateEvent = if (recipientPK == signer.pubKey) { // if the receiver is logged in, these are the params. - val privateKeyToUse = signer.keyPair.privKey - val pubkeyToUse = event.pubKey - - event.getPrivateZapEvent(privateKeyToUse, pubkeyToUse) + decryptAnonTag(event.getAnonTag(), signer.keyPair.privKey, event.pubKey) } else { // if the sender is logged in, these are the params val altPubkeyToUse = recipientPK - val altPrivateKeyToUse = + val myPrivateKeyForThisEvent = if (recipientPost != null) { PrivateZapEncryption.createEncryptionPrivateKey( signer.keyPair.privKey.toHexKey(), @@ -100,35 +101,44 @@ class PrivateZapRequestBuilder { event.createdAt, ) } else { - null + throw SignerExceptions.CouldNotPerformException("Couldn't find a secret to use. The private zap is neither a post nor an author zap") } try { - if (altPrivateKeyToUse != null && altPubkeyToUse != null) { - val altPubKeyFromPrivate = Nip01.pubKeyCreate(altPrivateKeyToUse).toHexKey() + if (altPubkeyToUse != null) { + val altPubKeyFromPrivate = Nip01.pubKeyCreate(myPrivateKeyForThisEvent).toHexKey() if (altPubKeyFromPrivate == event.pubKey) { - val result = event.getPrivateZapEvent(altPrivateKeyToUse, altPubkeyToUse) - - if (result == null) { - Log.w( - "Private ZAP Decrypt", - "Fail to decrypt Zap from ${event.id}", - ) - } - result + // the sender is logged in. + decryptAnonTag(event.getAnonTag(), myPrivateKeyForThisEvent, altPubkeyToUse) } else { - null + throw SignerExceptions.CouldNotPerformException("This private zap cannot be decrypted by this key.") } } else { - null + throw SignerExceptions.CouldNotPerformException("Recipient pubkey not found.") } } catch (e: Exception) { - Log.e("Account", "Failed to create pubkey for ZapRequest ${event.id}", e) - null + throw SignerExceptions.CouldNotPerformException("Failed to create pubkey for ZapRequest ${event.id}. ${e.message}") } } return privateEvent } + + fun decryptAnonTag( + encNote: String, + privateKey: ByteArray, + pubKey: HexKey, + ): LnZapPrivateEvent = + try { + val note = PrivateZapEncryption.decryptPrivateZapMessage(encNote, privateKey, pubKey.hexToByteArray()) + val decryptedEvent = fromJson(note) + if (decryptedEvent.kind == 9733) { + decryptedEvent as LnZapPrivateEvent + } else { + throw IllegalStateException("The decrypted event is not a private zap.") + } + } catch (e: Exception) { + throw IllegalStateException("Could not decrypt private zap. ${e.message}") + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/BaseZapSplitSetup.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/BaseZapSplitSetup.kt index e81c4094d6..a0b921b1c4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/BaseZapSplitSetup.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/BaseZapSplitSetup.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/EventExt.kt index 5c865e3af0..718e44a4d6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/EventExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/TagArrayBuilderExt.kt index 6448b919b9..ccbc903953 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/TagArrayExt.kt index 2775ce1d13..a389c7cd35 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/TagArrayExt.kt @@ -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,9 +21,7 @@ package com.vitorpamplona.quartz.nip57Zaps.splits import com.vitorpamplona.quartz.nip01Core.core.TagArray -import com.vitorpamplona.quartz.nip01Core.core.hasTagWithContent -import com.vitorpamplona.quartz.nip01Core.core.mapTagged -fun TagArray.hasZapSplitSetup() = this.hasTagWithContent(BaseZapSplitSetup.TAG_NAME) +fun TagArray.hasZapSplitSetup() = this.any(ZapSplitSetupParser::isTagged) -fun TagArray.zapSplitSetup(): List = this.mapTagged(BaseZapSplitSetup.TAG_NAME) { ZapSplitSetupParser.parse(it) } +fun TagArray.zapSplitSetup(): List = this.mapNotNull(ZapSplitSetupParser::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetup.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetup.kt index 3988dfb60b..1f48c9a106 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetup.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetup.kt @@ -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,10 +21,11 @@ package com.vitorpamplona.quartz.nip57Zaps.splits import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl data class ZapSplitSetup( val pubKeyHex: HexKey, - val relay: String?, + val relay: NormalizedRelayUrl?, override val weight: Double, ) : BaseZapSplitSetup { override fun mainId() = pubKeyHex diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupLnAddress.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupLnAddress.kt index bcde9c3562..a8fb693dc3 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupLnAddress.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupLnAddress.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupParser.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupParser.kt index 0d2e15abc5..3924e101b9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupParser.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupParser.kt @@ -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,11 +20,19 @@ */ package com.vitorpamplona.quartz.nip57Zaps.splits +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.ensure + class ZapSplitSetupParser { companion object { + @JvmStatic + fun isTagged(tags: Array) = tags.has(1) && tags[0] == BaseZapSplitSetup.TAG_NAME + @JvmStatic fun parse(tags: Array): BaseZapSplitSetup? { - require(tags[0] == BaseZapSplitSetup.TAG_NAME) + ensure(tags.has(1)) { return null } + ensure(tags[0] == BaseZapSplitSetup.TAG_NAME) { return null } val isLnAddress = tags[1].contains("@") || tags[1].startsWith("LNURL", true) val weight = if (isLnAddress) 1.0 else (tags.getOrNull(3)?.toDoubleOrNull() ?: 0.0) @@ -33,9 +41,11 @@ class ZapSplitSetupParser { if (isLnAddress) { ZapSplitSetupLnAddress(tags[1], 1.0) } else { + val relayHint = tags.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + ZapSplitSetup( tags[1], - tags.getOrNull(2), + relayHint, weight, ) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupSerializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupSerializer.kt index 853c01a4f5..dbef78241f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupSerializer.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupSerializer.kt @@ -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,13 +20,15 @@ */ package com.vitorpamplona.quartz.nip57Zaps.splits +import com.vitorpamplona.quartz.utils.arrayOfNotNull + class ZapSplitSetupSerializer { companion object { @JvmStatic fun toTagArray(zapSplit: BaseZapSplitSetup): Array = when (zapSplit) { is ZapSplitSetupLnAddress -> arrayOf(BaseZapSplitSetup.TAG_NAME, zapSplit.lnAddress) - is ZapSplitSetup -> arrayOf(BaseZapSplitSetup.TAG_NAME, zapSplit.pubKeyHex, zapSplit.relay ?: "", zapSplit.weight.toString()) + is ZapSplitSetup -> arrayOfNotNull(BaseZapSplitSetup.TAG_NAME, zapSplit.pubKeyHex, zapSplit.relay?.url, zapSplit.weight.toString()) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/zapraiser/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/zapraiser/EventExt.kt index 414fa21835..8e1d382e87 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/zapraiser/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/zapraiser/EventExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/zapraiser/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/zapraiser/TagArrayBuilderExt.kt index d854525c29..1cd8caaa47 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/zapraiser/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/zapraiser/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/zapraiser/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/zapraiser/TagArrayExt.kt index 3f730960d9..7107d7cd5b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/zapraiser/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/zapraiser/TagArrayExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/zapraiser/ZapRaiserTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/zapraiser/ZapRaiserTag.kt index 66feb13969..fdc831fb65 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/zapraiser/ZapRaiserTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/zapraiser/ZapRaiserTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeAwardEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeAwardEvent.kt index b7074d7ebd..3f94a7c8f5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeAwardEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeAwardEvent.kt @@ -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,13 @@ package com.vitorpamplona.quartz.nip58Badges import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers @@ -35,7 +41,22 @@ class BadgeAwardEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + fun awardees() = taggedUsers() fun awardeeIds() = taggedUserIds() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeDefinitionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeDefinitionEvent.kt index a73e426044..78c9003a2a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeDefinitionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeDefinitionEvent.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeProfilesEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeProfilesEvent.kt index bdf3900419..2e47fcbcb4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeProfilesEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeProfilesEvent.kt @@ -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,10 +23,15 @@ package com.vitorpamplona.quartz.nip58Badges import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag @Immutable class BadgeProfilesEvent( @@ -36,7 +41,22 @@ class BadgeProfilesEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + fun badgeAwardEvents() = taggedEvents() fun badgeAwardDefinitions() = taggedAddresses() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/HostStub.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/HostStub.kt index dcef4c57f0..43a5f2dfe9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/HostStub.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/HostStub.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/WrappedEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/WrappedEvent.kt index facc1065b3..d7687bf21c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/WrappedEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/WrappedEvent.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/rumors/Rumor.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/rumors/Rumor.kt index ccbec1c13f..1a133acb3b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/rumors/Rumor.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/rumors/Rumor.kt @@ -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,7 +25,7 @@ import com.vitorpamplona.quartz.EventFactory import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent class Rumor( @@ -49,9 +49,9 @@ class Rumor( } companion object { - fun fromJson(json: String): Rumor = EventMapper.mapper.readValue(json, Rumor::class.java) + fun fromJson(json: String): Rumor = JsonMapper.mapper.readValue(json, Rumor::class.java) - fun toJson(event: Rumor): String = EventMapper.mapper.writeValueAsString(event) + fun toJson(event: Rumor): String = JsonMapper.mapper.writeValueAsString(event) fun create(event: Event): Rumor = Rumor(event.id, event.pubKey, event.createdAt, event.kind, event.tags, event.content) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/rumors/RumorAssembler.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/rumors/RumorAssembler.kt new file mode 100644 index 0000000000..65e7a61fc7 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/rumors/RumorAssembler.kt @@ -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.quartz.nip59Giftwrap.rumors + +import com.vitorpamplona.quartz.EventFactory +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate + +class RumorAssembler { + companion object { + fun assembleRumor( + pubKey: HexKey, + ev: EventTemplate, + ) = assembleRumor( + pubKey, + ev.createdAt, + ev.kind, + ev.tags, + ev.content, + ) + + fun assembleRumor( + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ) = EventFactory.create( + id = EventHasher.hashId(pubKey, createdAt, kind, tags, content), + pubKey = pubKey, + createdAt = createdAt, + kind = kind, + tags = tags, + content = content, + sig = "", + ) as T + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/rumors/RumorDeserializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/rumors/RumorDeserializer.kt index f683e44e44..888b64c421 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/rumors/RumorDeserializer.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/rumors/RumorDeserializer.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/rumors/RumorSerializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/rumors/RumorSerializer.kt index a7fb1d0aa0..8c19df501b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/rumors/RumorSerializer.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/rumors/RumorSerializer.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt index a1b7c9dd27..c65005c192 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt @@ -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 @@ -66,74 +66,59 @@ class SealedRumorEvent( override fun isContentEncoded() = true - @Deprecated( - message = "Heavy caching was removed from this class due to high memory use. Cache it separatedly", - replaceWith = ReplaceWith("unseal"), - ) - fun cachedRumor( - signer: NostrSigner, - onReady: (Event) -> Unit, - ) = unseal(signer, onReady) + suspend fun unsealThrowing(signer: NostrSigner): Event { + val rumor = Rumor.fromJson(plainContent(signer)) - fun unseal( - signer: NostrSigner, - onReady: (Event) -> Unit, - ) { - try { - plainContent(signer) { - try { - val rumor = Rumor.fromJson(it) - val event = rumor.mergeWith(this) - if (event is WrappedEvent) { - event.host = host ?: HostStub(this.id, this.pubKey, this.kind) - } - innerEventId = event.id + val event = rumor.mergeWith(this) + if (event is WrappedEvent) { + event.host = host ?: HostStub(this.id, this.pubKey, this.kind) + } + innerEventId = event.id - onReady(event) - } catch (e: Exception) { - Log.w("RumorEvent", "Fail to decrypt or parse Rumor", e) - } - } + return event + } + + suspend fun unsealOrNull(signer: NostrSigner): Event? { + return try { + return unsealThrowing(signer) } catch (e: Exception) { Log.w("RumorEvent", "Fail to decrypt or parse Rumor", e) + null } } - private fun plainContent( - signer: NostrSigner, - onReady: (String) -> Unit, - ) { - if (content.isEmpty()) return + private suspend fun plainContent(signer: NostrSigner): String { + if (content.isEmpty()) return "" - signer.nip44Decrypt(content, pubKey, onReady) + return signer.nip44Decrypt(content, pubKey) } companion object { const val KIND = 13 - fun create( + suspend fun create( event: Event, encryptTo: HexKey, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (SealedRumorEvent) -> Unit, - ) { + ): SealedRumorEvent { val rumor = Rumor.create(event) - create(rumor, encryptTo, signer, createdAt, onReady) + return create(rumor, encryptTo, signer, createdAt) } - fun create( + suspend fun create( rumor: Rumor, encryptTo: HexKey, signer: NostrSigner, createdAt: Long = TimeUtils.randomWithTwoDays(), - onReady: (SealedRumorEvent) -> Unit, - ) { + ): SealedRumorEvent { val msg = Rumor.toJson(rumor) - - signer.nip44Encrypt(msg, encryptTo) { content -> - signer.sign(createdAt, KIND, emptyArray(), content, onReady) - } + return signer.sign( + createdAt = createdAt, + kind = KIND, + tags = emptyArray(), + content = signer.nip44Encrypt(msg, encryptTo), + ) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt index 321ba29e12..9b8fd93130 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt @@ -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 @@ -68,49 +68,31 @@ class GiftWrapEvent( override fun isContentEncoded() = true - @Deprecated( - message = "Heavy caching was removed from this class due to high memory use. Cache it separatedly", - replaceWith = ReplaceWith("unwrap"), - ) - fun cachedGift( - signer: NostrSigner, - onReady: (Event) -> Unit, - ) = unwrap(signer, onReady) + suspend fun unwrapThrowing(signer: NostrSigner): Event { + val giftStr = plainContent(signer) + val gift = fromJson(giftStr) - fun unwrapThrowing( - signer: NostrSigner, - onReady: (Event) -> Unit, - ) { - plainContent(signer) { giftStr -> - val gift = fromJson(giftStr) - - if (gift is WrappedEvent) { - gift.host = HostStub(this.id, this.pubKey, this.kind) - } - innerEventId = gift.id - - onReady(gift) + if (gift is WrappedEvent) { + gift.host = HostStub(this.id, this.pubKey, this.kind) } + innerEventId = gift.id + + return gift } - fun unwrap( - signer: NostrSigner, - onReady: (Event) -> Unit, - ) { + suspend fun unwrapOrNull(signer: NostrSigner): Event? { try { - unwrapThrowing(signer, onReady) - } catch (e: Exception) { + return unwrapThrowing(signer) + } catch (_: Exception) { Log.w("GiftWrapEvent", "Couldn't Decrypt the content " + this.toNostrUri()) + return null } } - private fun plainContent( - signer: NostrSigner, - onReady: (String) -> Unit, - ) { - if (content.isEmpty()) return + private suspend fun plainContent(signer: NostrSigner): String { + if (content.isEmpty()) return "" - signer.nip44Decrypt(content, pubKey, onReady) + return signer.nip44Decrypt(content, pubKey) } fun recipientPubKey() = tags.firstTagValue("p") @@ -119,19 +101,18 @@ class GiftWrapEvent( const val KIND = 1059 const val ALT = "Encrypted event" - fun create( + suspend fun create( event: Event, recipientPubKey: HexKey, createdAt: Long = TimeUtils.randomWithTwoDays(), - onReady: (GiftWrapEvent) -> Unit, - ) { + ): GiftWrapEvent { val signer = NostrSignerInternal(KeyPair()) // GiftWrap is always a random key - val serializedContent = event.toJson() - val tags = arrayOf(arrayOf("p", recipientPubKey)) - - signer.nip44Encrypt(serializedContent, recipientPubKey) { content -> - signer.sign(createdAt, KIND, tags, content, onReady) - } + return signer.sign( + createdAt = createdAt, + kind = KIND, + tags = arrayOf(arrayOf("p", recipientPubKey)), + content = signer.nip44Encrypt(event.toJson(), recipientPubKey), + ) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip62RequestToVanish/RequestToVanishEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip62RequestToVanish/RequestToVanishEvent.kt new file mode 100644 index 0000000000..110ce87145 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip62RequestToVanish/RequestToVanishEvent.kt @@ -0,0 +1,82 @@ +/** + * 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.quartz.nip62RequestToVanish + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip62RequestToVanish.tags.shouldVanishFrom +import com.vitorpamplona.quartz.nip62RequestToVanish.tags.vanishFrom +import com.vitorpamplona.quartz.nip62RequestToVanish.tags.vanishFromEverywhere +import com.vitorpamplona.quartz.nip62RequestToVanish.tags.vanishFromRelays +import com.vitorpamplona.quartz.utils.TimeUtils + +class RequestToVanishEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun vanishFromRelays() = tags.vanishFromRelays() + + fun shouldVanishFrom(relayUrl: String?) = tags.shouldVanishFrom(relayUrl) + + companion object { + const val KIND = 62 + const val ALT = "Request to vanish" + + fun build( + relay: String, + reason: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, reason, createdAt) { + alt(ALT) + vanishFrom(relay) + initializer() + } + + fun build( + relays: List, + reason: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, reason, createdAt) { + alt(ALT) + vanishFrom(relays) + initializer() + } + + fun buildVanishFromEverywhere( + reason: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, reason, createdAt) { + alt(ALT) + vanishFromEverywhere() + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip62RequestToVanish/tags/RelayTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip62RequestToVanish/tags/RelayTag.kt new file mode 100644 index 0000000000..13a1c1eb3c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip62RequestToVanish/tags/RelayTag.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip62RequestToVanish.tags + +import android.R.attr.tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class RelayTag { + companion object { + const val TAG_NAME = "relay" + const val EVERYWHERE = "ALL_RELAYS" + + @JvmStatic + fun match(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun notMatch(tag: Array) = tag.has(0) && tag[0] == TAG_NAME + + @JvmStatic + fun shouldVanishFrom( + tag: Array, + relayUrl: String?, + ) = tag.has(1) && tag[0] == TAG_NAME && (tag[1] == relayUrl || tag[1] == EVERYWHERE) + + @JvmStatic + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + @JvmStatic + fun assemble(relay: String) = arrayOf(TAG_NAME, relay) + + fun assembleEverywhere() = arrayOf(TAG_NAME, EVERYWHERE) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip62RequestToVanish/tags/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip62RequestToVanish/tags/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..7d29a1bf8c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip62RequestToVanish/tags/TagArrayBuilderExt.kt @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip62RequestToVanish.tags + +import com.vitorpamplona.quartz.experimental.ephemChat.chat.relay +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent +import kotlin.collections.map + +fun TagArrayBuilder.vanishFromEverywhere() = add(RelayTag.assembleEverywhere()) + +fun TagArrayBuilder.vanishFrom(relay: String) = add(RelayTag.assemble(relay)) + +fun TagArrayBuilder.vanishFrom(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip62RequestToVanish/tags/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip62RequestToVanish/tags/TagArrayExt.kt new file mode 100644 index 0000000000..1a81d454a7 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip62RequestToVanish/tags/TagArrayExt.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip62RequestToVanish.tags + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.any + +fun TagArray.vanishFromRelays() = mapNotNull(RelayTag::parse) + +fun TagArray.shouldVanishFrom(relayUrl: String?) = any(RelayTag::shouldVanishFrom, relayUrl) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt index 928ad17749..315f406b3d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt @@ -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,7 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address -import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -39,48 +39,15 @@ class AdvertisedRelayListEvent( content: String, sig: HexKey, ) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun relays(): List = - tags.mapNotNull { - if (it.size > 1 && it[0] == "r") { - val type = - when (it.getOrNull(2)) { - "read" -> AdvertisedRelayType.READ - "write" -> AdvertisedRelayType.WRITE - else -> AdvertisedRelayType.BOTH - } + fun relays() = tags.mapNotNull(AdvertisedRelayInfo::parse) - AdvertisedRelayInfo(it[1], type) - } else { - null - } - } + fun readRelays() = tags.mapNotNull(AdvertisedRelayInfo::parseRead).ifEmpty { null } - fun readRelays(): List? = - tags - .mapNotNull { - if (it.size > 1 && it[0] == "r") { - when (it.getOrNull(2)) { - "read" -> it[1] - "write" -> null - else -> it[1] - } - } else { - null - } - }.ifEmpty { null } + fun readRelaysNorm() = tags.mapNotNull(AdvertisedRelayInfo::parseReadNorm).ifEmpty { null } - fun writeRelays(): List = - tags.mapNotNull { - if (it.size > 1 && it[0] == "r") { - when (it.getOrNull(2)) { - "read" -> null - "write" -> it[1] - else -> it[1] - } - } else { - null - } - } + fun writeRelays() = tags.mapNotNull(AdvertisedRelayInfo::parseWrite).ifEmpty { null } + + fun writeRelaysNorm() = tags.mapNotNull(AdvertisedRelayInfo::parseWriteNorm).ifEmpty { null } companion object { const val KIND = 10002 @@ -92,80 +59,42 @@ class AdvertisedRelayListEvent( fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG) - fun updateRelayList( + suspend fun replaceRelayListWith( earlierVersion: AdvertisedRelayListEvent, + newRelays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): AdvertisedRelayListEvent { + val tags = earlierVersion.tags.filter(AdvertisedRelayInfo::notMatch) + val relayTags = newRelays.map { it.toTagArray() } + + return signer.sign(createdAt, KIND, (tags + relayTags).toTypedArray(), earlierVersion.content) + } + + suspend fun createFromScratch( relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (AdvertisedRelayListEvent) -> Unit, - ) { - val tags = - earlierVersion.tags - .filter { it[0] != "r" } - .plus( - relays.map(Companion::createRelayTag), - ).toTypedArray() + ) = create(relays, signer, createdAt) - signer.sign(createdAt, KIND, tags, earlierVersion.content, onReady) - } - - fun createFromScratch( - relays: List, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (AdvertisedRelayListEvent) -> Unit, - ) { - create(relays, signer, createdAt, onReady) - } - - fun createRelayTag(relay: AdvertisedRelayInfo): Array = - if (relay.type == AdvertisedRelayType.BOTH) { - arrayOf("r", relay.relayUrl) - } else { - arrayOf("r", relay.relayUrl, relay.type.code) - } - - fun createTagArray(relays: List): Array> = - relays - .map(Companion::createRelayTag) - .plusElement(AltTag.assemble(ALT)) - .toTypedArray() - - fun create( + suspend fun create( list: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (AdvertisedRelayListEvent) -> Unit, - ) { - val tags = createTagArray(list) - val msg = "" + ): AdvertisedRelayListEvent { + val tags = list.map { it.toTagArray() }.toTypedArray() - signer.sign(createdAt, KIND, tags, msg, onReady) + return signer.sign(createdAt, KIND, tags, "") } fun create( list: List, signer: NostrSignerSync, createdAt: Long = TimeUtils.now(), - ): AdvertisedRelayListEvent? { - val tags = createTagArray(list) - val msg = "" + ): AdvertisedRelayListEvent { + val tags = list.map { it.toTagArray() }.toTypedArray() - return signer.sign(createdAt, KIND, tags, msg) + return signer.sign(createdAt, KIND, tags, "") } } - - @Immutable data class AdvertisedRelayInfo( - val relayUrl: String, - val type: AdvertisedRelayType, - ) - - @Immutable - enum class AdvertisedRelayType( - val code: String, - ) { - BOTH(""), - READ("read"), - WRITE("write"), - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/RelayListRecommendationProcessor.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/RelayListRecommendationProcessor.kt index a87ab59f9b..5856ed926f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/RelayListRecommendationProcessor.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/RelayListRecommendationProcessor.kt @@ -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,31 +21,27 @@ package com.vitorpamplona.quartz.nip65RelayList import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion +import com.vitorpamplona.quartz.utils.mapOfSet class RelayListRecommendationProcessor { companion object { fun transpose( - userList: Map>, - ignore: Set = setOf(), - ): Map> { - val popularity = mutableMapOf>() - - userList.forEach { event -> - event.value.forEach { relayUrl -> - if (relayUrl !in ignore) { - val set = popularity[relayUrl] - if (set != null) { - set.add(event.key) - } else { - popularity[relayUrl] = mutableSetOf(event.key) + userList: Map>, + ignore: Set = setOf(), + ): Map> = + mapOfSet { + userList.forEach { event -> + event.value.forEach { relay -> + if (relay !in ignore) { + add(relay, event.key) } } } } - return popularity - } - /** * filter onion and local host from write relays * for each user pubkey, a list of valid relays. @@ -53,30 +49,20 @@ class RelayListRecommendationProcessor { private fun filterValidRelays( userList: List, hasOnionConnection: Boolean = false, - ): MutableMap> { - val validWriteRelayUrls = mutableMapOf>() - - userList.forEach { event -> - event.writeRelays().forEach { relayUrl -> - if (!RelayUrlFormatter.isLocalHost(relayUrl) && (hasOnionConnection || !RelayUrlFormatter.isOnion(relayUrl))) { - RelayUrlFormatter.normalizeOrNull(relayUrl)?.let { normRelayUrl -> - val set = validWriteRelayUrls[event.pubKey] - if (set != null) { - set.add(normRelayUrl) - } else { - validWriteRelayUrls[event.pubKey] = mutableSetOf(normRelayUrl) - } + ): Map> = + mapOfSet { + userList.forEach { event -> + event.writeRelaysNorm()?.forEach { relay -> + if (!relay.isLocalHost() && (hasOnionConnection || !relay.isOnion())) { + add(event.pubKey, relay) } } } } - return validWriteRelayUrls - } - fun reliableRelaySetFor( - usersAndRelays: MutableMap>, - relayUrlsToIgnore: Set = emptySet(), + usersAndRelays: Map>, + relayUrlsToIgnore: Set = emptySet(), ): Set { // ignores users that are already being served by the list. val usersToServeInTheFirstRound = @@ -145,7 +131,7 @@ class RelayListRecommendationProcessor { fun reliableRelaySetFor( userList: List, - relayUrlsToIgnore: Set = emptySet(), + relayUrlsToIgnore: Set = emptySet(), hasOnionConnection: Boolean = false, ): Set = reliableRelaySetFor( @@ -155,7 +141,7 @@ class RelayListRecommendationProcessor { } class RelayRecommendation( - val url: String, + val relay: NormalizedRelayUrl, val requiredToNotMissEvents: Boolean, val users: Set, ) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/RelayUrlFormatter.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/RelayUrlFormatter.kt deleted file mode 100644 index aab438aaf1..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/RelayUrlFormatter.kt +++ /dev/null @@ -1,83 +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.quartz.nip65RelayList - -import org.czeal.rfc3986.URIReference - -class RelayUrlFormatter { - companion object { - fun displayUrl(url: String): String = - url - .trim() - .removePrefix("wss://") - .removePrefix("ws://") - .removeSuffix("/") - - fun isLocalHost(url: String) = url.contains("127.0.0.1") || url.contains("localhost") - - fun isOnion(url: String) = url.endsWith(".onion") || url.endsWith(".onion/") - - fun normalize(url: String): String { - val newUrl = - if (!url.startsWith("wss://") && !url.startsWith("ws://")) { - if (isOnion(url) || isLocalHost(url)) { - "ws://${url.trim()}" - } else { - "wss://${url.trim()}" - } - } else { - url.trim() - } - - return try { - URIReference.parse(newUrl).normalize().toString() - } catch (e: Exception) { - newUrl - } - } - - fun normalizeOrNull(url: String): String? { - val newUrl = - if (!url.startsWith("wss://") && !url.startsWith("ws://")) { - if (isOnion(url) || isLocalHost(url)) { - "ws://${url.trim()}" - } else { - "wss://${url.trim()}" - } - } else { - url.trim() - } - - return try { - URIReference.parse(newUrl).normalize().toString() - } catch (e: Exception) { - null - } - } - - fun getHttpsUrl(dirtyUrl: String): String = - if (dirtyUrl.contains("://")) { - dirtyUrl.replace("wss://", "https://").replace("ws://", "http://") - } else { - "https://$dirtyUrl" - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/tags/AdvertisedRelayInfoTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/tags/AdvertisedRelayInfoTag.kt new file mode 100644 index 0000000000..bdde75e007 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/tags/AdvertisedRelayInfoTag.kt @@ -0,0 +1,132 @@ +/** + * 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.quartz.nip65RelayList.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost +import com.vitorpamplona.quartz.utils.ensure + +class AdvertisedRelayInfo( + val relayUrl: NormalizedRelayUrl, + val type: AdvertisedRelayType, +) { + fun toTagArray() = assemble(relayUrl, type) + + companion object { + const val TAG_NAME = "r" + + fun match(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun notMatch(tag: Array) = !match(tag) + + @JvmStatic + fun parse(tag: Array): AdvertisedRelayInfo? { + ensure(match(tag)) { return null } + + val normalizedUrl = RelayUrlNormalizer.normalizeOrNull(tag[1]) + + ensure(normalizedUrl != null) { return null } + + val type = + when (tag.getOrNull(2)) { + AdvertisedRelayType.READ.code -> AdvertisedRelayType.READ + AdvertisedRelayType.WRITE.code -> AdvertisedRelayType.WRITE + else -> AdvertisedRelayType.BOTH + } + + return AdvertisedRelayInfo(normalizedUrl, type) + } + + @JvmStatic + fun parseRead(tag: Array): String? { + ensure(match(tag)) { return null } + ensure(AdvertisedRelayType.isRead(tag.getOrNull(2))) { return null } + + return tag[1] + } + + @JvmStatic + fun parseWrite(tag: Array): String? { + ensure(match(tag)) { return null } + ensure(AdvertisedRelayType.isWrite(tag.getOrNull(2))) { return null } + + return tag[1] + } + + @JvmStatic + fun parseReadNorm(tag: Array): NormalizedRelayUrl? { + ensure(match(tag)) { return null } + ensure(AdvertisedRelayType.isRead(tag.getOrNull(2))) { return null } + + val relay = RelayUrlNormalizer.normalizeOrNull(tag[1]) + + ensure(relay != null && !relay.isLocalHost()) { return null } + + return RelayUrlNormalizer.normalizeOrNull(tag[1]) + } + + @JvmStatic + fun parseWriteNorm(tag: Array): NormalizedRelayUrl? { + ensure(match(tag)) { return null } + ensure(AdvertisedRelayType.isWrite(tag.getOrNull(2))) { return null } + + val relay = RelayUrlNormalizer.normalizeOrNull(tag[1]) + + ensure(relay != null && !relay.isLocalHost()) { return null } + + return relay + } + + @JvmStatic + fun assemble( + relay: NormalizedRelayUrl, + type: AdvertisedRelayType, + ): Array = + if (type == AdvertisedRelayType.BOTH) { + arrayOf(TAG_NAME, relay.url) + } else { + arrayOf(TAG_NAME, relay.url, type.code) + } + } +} + +@Immutable +enum class AdvertisedRelayType( + val code: String, +) { + BOTH(""), + READ("read"), + WRITE("write"), + ; + + fun isRead() = this == READ || this == BOTH + + fun isWrite() = this == WRITE || this == BOTH + + companion object { + fun isRead(type: String?) = type == null || type.isBlank() || type == AdvertisedRelayType.READ.code + + fun isWrite(type: String?) = type == null || type.isBlank() || type == AdvertisedRelayType.WRITE.code + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/IMetaTagBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/IMetaTagBuilderExt.kt index 620754dd7b..bd916f9dc6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/IMetaTagBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/IMetaTagBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/IMetaTagExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/IMetaTagExt.kt index 30fe0ee2da..5766cc4956 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/IMetaTagExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/IMetaTagExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/PictureEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/PictureEvent.kt index d14368859e..7315ecf396 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/PictureEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/PictureEvent.kt @@ -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,25 +24,16 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder -import com.vitorpamplona.quartz.nip01Core.core.any import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip22Comments.RootScope import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip68Picture.tags.LocationTag import com.vitorpamplona.quartz.nip92IMeta.imetas -import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag -import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag -import com.vitorpamplona.quartz.nip94FileMetadata.tags.FallbackTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag -import com.vitorpamplona.quartz.nip94FileMetadata.tags.ImageTag -import com.vitorpamplona.quartz.nip94FileMetadata.tags.MagnetTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag -import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag -import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag -import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag -import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash -import com.vitorpamplona.quartz.nip94FileMetadata.tags.UrlTag -import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -55,63 +46,21 @@ class PictureEvent( sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), RootScope { - // --------------- - // current - // -------------- + @Transient var iMetas: List? = null fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) - /** old standard didnt use IMetas **/ - private fun url() = tags.firstNotNullOfOrNull(UrlTag::parse) + fun mimeType() = tags.firstNotNullOfOrNull(MimeTypeTag::parse) - private fun urls() = tags.mapNotNull(UrlTag::parse) + fun hash() = tags.firstNotNullOfOrNull(HashSha256Tag::parse) - private fun mimeType() = tags.firstNotNullOfOrNull(MimeTypeTag::parse) + fun hashtags() = tags.hashtags() - private fun hash() = tags.firstNotNullOfOrNull(HashSha256Tag::parse) + fun geohashes() = tags.geohashes() - private fun size() = tags.firstNotNullOfOrNull(SizeTag::parse) + fun location() = tags.mapNotNull(LocationTag::parse) - private fun dimensions() = tags.firstNotNullOfOrNull(DimensionTag::parse) - - private fun magnetURI() = tags.firstNotNullOfOrNull(MagnetTag::parse) - - private fun torrentInfoHash() = tags.firstNotNullOfOrNull(TorrentInfoHash::parse) - - private fun blurhash() = tags.firstNotNullOfOrNull(BlurhashTag::parse) - - private fun hasUrl() = tags.any(UrlTag::isTag) - - private fun isOneOf(mimeTypes: Set) = tags.any(MimeTypeTag::isIn, mimeTypes) - - private fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) - - private fun images() = tags.mapNotNull(ImageTag::parse) - - private fun thumb() = tags.firstNotNullOfOrNull(ThumbTag::parse) - - private fun service() = tags.firstNotNullOfOrNull(ServiceTag::parse) - - private fun fallbacks() = tags.mapNotNull(FallbackTag::parse) - - // hack to fix pablo's bug - fun rootImage() = - url()?.let { - PictureMeta( - url = it, - mimeType = mimeType(), - blurhash = blurhash(), - alt = alt(), - hash = hash(), - dimension = dimensions(), - size = size(), - service = service(), - fallback = fallbacks(), - annotations = emptyList(), - ) - } - - fun imetaTags() = imetas().map { PictureMeta.parse(it) }.plus(rootImage()).filterNotNull() + fun imetaTags() = iMetas ?: imetas().map { PictureMeta.parse(it) }.also { iMetas = it } companion object { const val KIND = 20 @@ -142,7 +91,7 @@ class PictureEvent( createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, description, createdAt) { - alt(ClassifiedsEvent.ALT_DESCRIPTION) + alt(ALT_DESCRIPTION) initializer() } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/PictureMeta.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/PictureMeta.kt index df1ffab630..f454bdb7ad 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/PictureMeta.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/PictureMeta.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/TagArrayBuilderExt.kt index 4c7c8cb5e4..feaa1f1b42 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/tags/LocationTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/tags/LocationTag.kt new file mode 100644 index 0000000000..a46e0e2ee1 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/tags/LocationTag.kt @@ -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.quartz.nip68Picture.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class LocationTag { + companion object { + const val TAG_NAME = "location" + + @JvmStatic + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + @JvmStatic + fun assemble(locationName: String) = arrayOf(TAG_NAME, locationName) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/tags/UserAnnotationTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/tags/UserAnnotationTag.kt index 704da38162..69d2f1d19b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/tags/UserAnnotationTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip68Picture/tags/UserAnnotationTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip70ProtectedEvts/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip70ProtectedEvts/EventExt.kt index 62fe0ab620..ca48ed2d4f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip70ProtectedEvts/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip70ProtectedEvts/EventExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip70ProtectedEvts/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip70ProtectedEvts/TagArrayBuilderExt.kt index 3f820f5538..ef48f09967 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip70ProtectedEvts/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip70ProtectedEvts/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip70ProtectedEvts/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip70ProtectedEvts/TagArrayExt.kt index 27260e8b1e..90bcaac260 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip70ProtectedEvts/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip70ProtectedEvts/TagArrayExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip70ProtectedEvts/tags/ProtectedTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip70ProtectedEvts/tags/ProtectedTag.kt index c9027d9dcb..8088d65064 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip70ProtectedEvts/tags/ProtectedTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip70ProtectedEvts/tags/ProtectedTag.kt @@ -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,6 +20,7 @@ */ package com.vitorpamplona.quartz.nip70ProtectedEvts.tags +import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.utils.ensure @@ -28,14 +29,14 @@ class ProtectedTag { const val TAG_NAME = "-" @JvmStatic - fun match(tag: Array): Boolean { + fun match(tag: Tag): Boolean { ensure(tag.has(0)) { return false } ensure(tag[0] == TAG_NAME) { return false } return true } @JvmStatic - fun parse(tag: Array): Boolean? { + fun parse(tag: Tag): Boolean? { ensure(tag.has(0)) { return null } ensure(tag[0] == TAG_NAME) { return null } return true diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/IMetaTagBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/IMetaTagBuilderExt.kt index c902f1f586..10607147c5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/IMetaTagBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/IMetaTagBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/IMetaTagExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/IMetaTagExt.kt index aa17ec54ac..fcd6882734 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/IMetaTagExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/IMetaTagExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/TagArrayBuilderExt.kt index f64c18ec52..8eeaabd5b1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoEvent.kt index 6b7588e5d1..2d2b61cc9f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoEvent.kt @@ -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,29 +23,17 @@ package com.vitorpamplona.quartz.nip71Video import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.any import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip22Comments.RootScope import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag -import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip71Video.tags.DurationTag import com.vitorpamplona.quartz.nip71Video.tags.SegmentTag import com.vitorpamplona.quartz.nip92IMeta.imetas -import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag -import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag -import com.vitorpamplona.quartz.nip94FileMetadata.tags.FallbackTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag -import com.vitorpamplona.quartz.nip94FileMetadata.tags.ImageTag -import com.vitorpamplona.quartz.nip94FileMetadata.tags.MagnetTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag -import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag -import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag -import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag -import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash -import com.vitorpamplona.quartz.nip94FileMetadata.tags.UrlTag @Immutable abstract class VideoEvent( @@ -58,59 +46,7 @@ abstract class VideoEvent( sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig), RootScope { - /** old standard didnt use IMetas **/ - private fun url() = tags.firstNotNullOfOrNull(UrlTag::parse) - - private fun urls() = tags.mapNotNull(UrlTag::parse) - - private fun mimeType() = tags.firstNotNullOfOrNull(MimeTypeTag::parse) - - private fun hash() = tags.firstNotNullOfOrNull(HashSha256Tag::parse) - - private fun size() = tags.firstNotNullOfOrNull(SizeTag::parse) - - private fun dimensions() = tags.firstNotNullOfOrNull(DimensionTag::parse) - - private fun magnetURI() = tags.firstNotNullOfOrNull(MagnetTag::parse) - - private fun torrentInfoHash() = tags.firstNotNullOfOrNull(TorrentInfoHash::parse) - - private fun blurhash() = tags.firstNotNullOfOrNull(BlurhashTag::parse) - - private fun hasUrl() = tags.any(UrlTag::isTag) - - private fun isOneOf(mimeTypes: Set) = tags.any(MimeTypeTag::isIn, mimeTypes) - - private fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) - - private fun images() = tags.mapNotNull(ImageTag::parse) - - private fun thumb() = tags.firstNotNullOfOrNull(ThumbTag::parse) - - private fun service() = tags.firstNotNullOfOrNull(ServiceTag::parse) - - private fun fallbacks() = tags.mapNotNull(FallbackTag::parse) - - // hack to fix pablo's bug - fun rootVideo() = - url()?.let { - VideoMeta( - url = it, - mimeType = mimeType(), - blurhash = blurhash(), - alt = alt(), - hash = hash(), - dimension = dimensions(), - size = size(), - service = service(), - fallback = fallbacks(), - image = images().map { it.imageUrl }, - ) - } - - // --------------- - // current - // -------------- + @Transient var iMetas: List? = null fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) @@ -126,5 +62,9 @@ abstract class VideoEvent( fun hashtags() = tags.hashtags() - fun imetaTags() = imetas().map { VideoMeta.parse(it) }.plus(rootVideo()).filterNotNull() + private fun mimeType() = tags.firstNotNullOfOrNull(MimeTypeTag::parse) + + private fun hash() = tags.firstNotNullOfOrNull(HashSha256Tag::parse) + + fun imetaTags() = iMetas ?: imetas().map { VideoMeta.parse(it) }.also { iMetas = it } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoHorizontalEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoHorizontalEvent.kt index 905deb4439..308b1b6e44 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoHorizontalEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoHorizontalEvent.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoMeta.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoMeta.kt index 6d44e722ce..33df020812 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoMeta.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoMeta.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoVerticalEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoVerticalEvent.kt index 8b8ccf616a..b3ae883fe2 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoVerticalEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoVerticalEvent.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/tags/DurationTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/tags/DurationTag.kt index fc378cc8a3..d2862f8abd 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/tags/DurationTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/tags/DurationTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/tags/SegmentTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/tags/SegmentTag.kt index 2d4a09bd46..a4e00839e4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/tags/SegmentTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/tags/SegmentTag.kt @@ -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,8 +25,10 @@ import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure class SegmentTag( - val start: String, // HH:MM:SS.sss - val end: String, // HH:MM:SS.sss + // HH:MM:SS.sss + val start: String, + // HH:MM:SS.sss + val end: String, val title: String, val thumbnailUrl: String?, ) { @@ -47,8 +49,10 @@ class SegmentTag( @JvmStatic fun assemble( - start: String, // HH:MM:SS.sss - end: String, // HH:MM:SS.sss + // HH:MM:SS.sss + start: String, + // HH:MM:SS.sss + end: String, title: String, thumbnailUrl: String?, ) = arrayOfNotNull(TAG_NAME, start, end, title, thumbnailUrl) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/tags/TextTrackTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/tags/TextTrackTag.kt index a624e0c094..671c77ca17 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/tags/TextTrackTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/tags/TextTrackTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/CommunityListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/CommunityListEvent.kt deleted file mode 100644 index 6035cae274..0000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/CommunityListEvent.kt +++ /dev/null @@ -1,279 +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.quartz.nip72ModCommunities - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent.Companion.FIXED_D_TAG -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip19Bech32.parseAtag -import com.vitorpamplona.quartz.nip31Alts.AltTag -import com.vitorpamplona.quartz.nip51Lists.GeneralListEvent -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.utils.pointerSizeInBytes -import kotlinx.collections.immutable.ImmutableSet -import kotlinx.collections.immutable.toImmutableSet - -@Immutable -class CommunityListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - @Transient var publicAndPrivateEventCache: ImmutableSet? = null - - override fun countMemory(): Long = - super.countMemory() + - 32 + (publicAndPrivateEventCache?.sumOf { pointerSizeInBytes + it.countMemory() } ?: 0L) // rough calculation - - override fun dTag() = FIXED_D_TAG - - fun publicAndPrivateEvents( - signer: NostrSigner, - onReady: (ImmutableSet) -> Unit, - ) { - publicAndPrivateEventCache?.let { eventList -> - onReady(eventList) - return - } - - privateTagsOrEmpty(signer) { - publicAndPrivateEventCache = - filterTagList("a", it) - .mapNotNull { ATag.parseAtag(it, null) } - .toImmutableSet() - - publicAndPrivateEventCache?.let { eventList -> - onReady(eventList) - } - } - } - - companion object { - const val KIND = 10004 - const val ALT = "Community List" - - fun blockListFor(pubKeyHex: HexKey): String = "$KIND:$pubKeyHex:" - - fun createListWithTag( - key: String, - tag: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) { - if (isPrivate) { - encryptTags(arrayOf(arrayOf(key, tag)), signer) { encryptedTags -> - create( - content = encryptedTags, - tags = emptyArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } else { - create( - content = "", - tags = arrayOf(arrayOf(key, tag)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - - fun createListWithEvent( - address: ATag, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) = createListWithTag("a", address.toTag(), isPrivate, signer, createdAt, onReady) - - fun addEvents( - earlierVersion: CommunityListEvent, - listAddresses: List, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = - privateTags.plus( - listAddresses.map { arrayOf("a", it.toTag()) }, - ), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = earlierVersion.tags, - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } else { - create( - content = earlierVersion.content, - tags = - earlierVersion.tags.plus( - listAddresses.map { arrayOf("a", it.toTag()) }, - ), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - - fun addEvent( - earlierVersion: CommunityListEvent, - address: ATag, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) = addTag(earlierVersion, "a", address.toTag(), isPrivate, signer, createdAt, onReady) - - fun addTag( - earlierVersion: CommunityListEvent, - key: String, - tag: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) { - earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> - if (!isTagged) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = privateTags.plus(element = arrayOf(key, tag)), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = earlierVersion.tags, - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } else { - create( - content = earlierVersion.content, - tags = earlierVersion.tags.plus(element = arrayOf(key, tag)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } - } - - fun removeEvent( - earlierVersion: CommunityListEvent, - address: ATag, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) = removeTag(earlierVersion, "a", address.toTag(), isPrivate, signer, createdAt, onReady) - - fun removeTag( - earlierVersion: CommunityListEvent, - key: String, - tag: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) { - earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> - if (isTagged) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = - privateTags - .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } - .toTypedArray(), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = - earlierVersion.tags - .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } - .toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } else { - create( - content = earlierVersion.content, - tags = - earlierVersion.tags - .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } - .toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } - } - - fun create( - content: String, - tags: Array>, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) { - val newTags = - if (tags.any { it.size > 1 && it[0] == "alt" }) { - tags - } else { - tags + AltTag.assemble(ALT) - } - - signer.sign(createdAt, KIND, newTags, content, onReady) - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/approval/CommunityPostApprovalEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/approval/CommunityPostApprovalEvent.kt index 718b0c5616..6cba530dd5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/approval/CommunityPostApprovalEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/approval/CommunityPostApprovalEvent.kt @@ -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,12 +25,19 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedATags import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents import com.vitorpamplona.quartz.nip01Core.tags.kinds.kind +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -43,7 +50,22 @@ class CommunityPostApprovalEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + tags.mapNotNull(QTag::parseEventAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + tags.mapNotNull(QTag::parseEventId) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + tags.mapNotNull(QTag::parseAddressAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + tags.mapNotNull(QTag::parseAddressId) + + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + fun containedPost(): Event? = try { content.ifBlank { null }?.let { fromJson(it) } @@ -68,6 +90,7 @@ class CommunityPostApprovalEvent( companion object { const val KIND = 4550 const val ALT_DESCRIPTION = "Community post approval" + val KIND_LIST = listOf(CommunityPostApprovalEvent.KIND) fun build( approvedPost: EventHintBundle, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/approval/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/approval/TagArrayBuilderExt.kt index d003f195b4..870bd9df68 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/approval/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/approval/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/CommunityDefinitionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/CommunityDefinitionEvent.kt index 58c548b2bc..11ffee913f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/CommunityDefinitionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/CommunityDefinitionEvent.kt @@ -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,15 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.DescriptionTag import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ImageTag @@ -44,7 +51,25 @@ class CommunityDefinitionEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + tags.mapNotNull(QTag::parseEventAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + tags.mapNotNull(QTag::parseEventId) + + override fun addressHints() = + tags.mapNotNull(ATag::parseAsHint) + + tags.mapNotNull(QTag::parseAddressAsHint) + + tags.mapNotNull(RelayTag::parse).map { AddressHint(addressTag(), it.url) } + + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + tags.mapNotNull(QTag::parseAddressId) + + override fun pubKeyHints() = tags.mapNotNull(ModeratorTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(ModeratorTag::parseKey) + fun name() = tags.firstNotNullOfOrNull(NameTag::parse) fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse) @@ -59,6 +84,8 @@ class CommunityDefinitionEvent( fun relays() = tags.mapNotNull(RelayTag::parse) + fun relayUrls() = tags.mapNotNull(RelayTag::parseUrls) + companion object { const val KIND = 34550 const val ALT_DESCRIPTION = "Community definition" diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/TagArrayBuilderExt.kt index e0aa6e2c37..111c6916ed 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/DescriptionTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/DescriptionTag.kt index 5e76b7ed9e..107c5ea787 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/DescriptionTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/DescriptionTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/ImageTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/ImageTag.kt index 2f3a7dbe3e..5c1fc2a650 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/ImageTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/ImageTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/ModeratorTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/ModeratorTag.kt index ddd2b46a2d..2bccc47901 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/ModeratorTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/ModeratorTag.kt @@ -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,6 +24,9 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -31,7 +34,7 @@ import com.vitorpamplona.quartz.utils.ensure @Immutable data class ModeratorTag( override val pubKey: String, - override val relayHint: String?, + override val relayHint: NormalizedRelayUrl?, val role: String?, ) : PubKeyReferenceTag { fun toTagArray() = assemble(pubKey, relayHint, role) @@ -44,7 +47,10 @@ data class ModeratorTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return ModeratorTag(tag[1], tag.getOrNull(2), tag.getOrNull(3)) + + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return ModeratorTag(tag[1], hint, tag.getOrNull(3)) } @JvmStatic @@ -55,11 +61,24 @@ data class ModeratorTag( return tag[1] } + @JvmStatic + fun parseAsHint(tag: Array): PubKeyHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val hint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(hint != null) { return null } + + return PubKeyHint(tag[1], hint) + } + @JvmStatic fun assemble( pubkey: HexKey, - relayHint: String?, + relayHint: NormalizedRelayUrl?, role: String?, - ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint, role) + ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint?.url, role) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/NameTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/NameTag.kt index 5f23b0f827..91fba35934 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/NameTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/NameTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RelayTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RelayTag.kt index 6e5c4dd14c..09e62790d6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RelayTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RelayTag.kt @@ -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,11 +21,13 @@ package com.vitorpamplona.quartz.nip72ModCommunities.definition.tags import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure class RelayTag( - val url: String, + val url: NormalizedRelayUrl, val marker: String? = null, ) { fun toTagArray() = assemble(url, marker) @@ -38,13 +40,27 @@ class RelayTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } - return RelayTag(tag[1], tag.getOrNull(2)) + + val relay = RelayUrlNormalizer.normalizeOrNull(tag[1]) ?: return null + + return RelayTag(relay, tag.getOrNull(2)) + } + + @JvmStatic + fun parseUrls(tag: Array): NormalizedRelayUrl? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + val relay = RelayUrlNormalizer.normalizeOrNull(tag[1]) ?: return null + + return relay } @JvmStatic fun assemble( - url: String, + relay: NormalizedRelayUrl, marker: String? = null, - ) = arrayOfNotNull(TAG_NAME, url, marker) + ) = arrayOfNotNull(TAG_NAME, relay.url, marker) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RulesTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RulesTag.kt index 3908eca080..2db22f6daa 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RulesTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RulesTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/CommunityListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/CommunityListEvent.kt new file mode 100644 index 0000000000..aaaaab456e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/CommunityListEvent.kt @@ -0,0 +1,230 @@ +/** + * 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.quartz.nip72ModCommunities.follow + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.nip51Lists.removeAny +import com.vitorpamplona.quartz.nip72ModCommunities.follow.tags.CommunityTag +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.plus + +@Immutable +class CommunityListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + AddressHintProvider { + override fun addressHints() = tags.mapNotNull(CommunityTag::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(CommunityTag::parseAddressId) + + fun publicCommunities() = tags.communities() + + fun publicCommunityIds() = tags.communityIds() + + companion object { + const val KIND = 10004 + const val ALT = "Community List" + const val FIXED_D_TAG = "" + + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) + + suspend fun create( + communities: List, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): CommunityListEvent = + if (isPrivate) { + create( + publicCommunities = emptyList(), + privateCommunities = communities, + signer = signer, + createdAt = createdAt, + ) + } else { + create( + publicCommunities = communities, + privateCommunities = emptyList(), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun create( + community: CommunityTag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): CommunityListEvent = + if (isPrivate) { + create( + publicCommunities = emptyList(), + privateCommunities = listOf(community), + signer = signer, + createdAt = createdAt, + ) + } else { + create( + publicCommunities = listOf(community), + privateCommunities = emptyList(), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun add( + earlierVersion: CommunityListEvent, + communities: List, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): CommunityListEvent = + if (isPrivate) { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + tags = earlierVersion.tags, + privateTags = privateTags.removeAny(communities.map { it.toTagIdOnly() }) + communities.map { it.toTagArray() }, + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.removeAny(communities.map { it.toTagIdOnly() }) + communities.map { it.toTagArray() }, + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun add( + earlierVersion: CommunityListEvent, + community: CommunityTag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): CommunityListEvent = + if (isPrivate) { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + tags = earlierVersion.tags, + privateTags = privateTags.plus(community.toTagArray()), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.plus(community.toTagArray()), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun remove( + earlierVersion: CommunityListEvent, + community: CommunityTag, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): CommunityListEvent { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + return resign( + privateTags = privateTags.remove(community.toTagIdOnly()), + tags = earlierVersion.tags.remove(community.toTagIdOnly()), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun resign( + tags: TagArray, + privateTags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = resign( + content = PrivateTagsInContent.encryptNip04(privateTags, signer), + tags = tags, + signer = signer, + createdAt = createdAt, + ) + + suspend fun resign( + content: String, + tags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): CommunityListEvent { + val newTags = + if (tags.fastAny(AltTag::match)) { + tags + } else { + tags + AltTag.assemble(ALT) + } + + return signer.sign(createdAt, KIND, newTags, content) + } + + suspend fun create( + publicCommunities: List = emptyList(), + privateCommunities: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): CommunityListEvent { + val template = build(publicCommunities, privateCommunities, signer, createdAt) + return signer.sign(template) + } + + suspend fun build( + publicCommunities: List = emptyList(), + privateCommunities: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip04(privateCommunities.map { it.toTagArray() }.toTypedArray(), signer), + createdAt = createdAt, + ) { + alt(ALT) + communities(publicCommunities) + + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..af274d9efc --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/TagArrayBuilderExt.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip72ModCommunities.follow + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +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.nip51Lists.tags.NameTag +import com.vitorpamplona.quartz.nip72ModCommunities.follow.tags.CommunityTag + +fun TagArrayBuilder.name(name: String) = addUnique(NameTag.assemble(name)) + +fun TagArrayBuilder.communities(communities: List) = addAll(communities.map { it.toTagArray() }) + +fun TagArrayBuilder.followCommunity( + address: Address, + relayUrl: NormalizedRelayUrl, +) = addUnique(ATag.assemble(address, relayUrl)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/TagArrayExt.kt new file mode 100644 index 0000000000..ca2b50b67b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/TagArrayExt.kt @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip72ModCommunities.follow + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip72ModCommunities.follow.tags.CommunityTag + +fun TagArray.communities() = mapNotNull(CommunityTag::parse) + +fun TagArray.communityIds() = mapNotNull(CommunityTag::parseAddressId) + +fun TagArray.communityIdSet() = mapNotNullTo(mutableSetOf(), CommunityTag::parseValidAddress) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/tags/CommunityTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/tags/CommunityTag.kt new file mode 100644 index 0000000000..7b6bcc6d35 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/tags/CommunityTag.kt @@ -0,0 +1,150 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip72ModCommunities.follow.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.bytesUsedInMemory +import com.vitorpamplona.quartz.utils.ensure +import com.vitorpamplona.quartz.utils.pointerSizeInBytes + +class CommunityTag( + val address: Address, + val relayHint: NormalizedRelayUrl? = null, +) { + fun countMemory(): Long = 2 * pointerSizeInBytes + address.countMemory() + (relayHint?.url?.bytesUsedInMemory() ?: 0) + + fun toTag() = Address.assemble(address.kind, address.pubKeyHex, address.dTag) + + fun toTagArray() = assemble(address, relayHint) + + fun toTagIdOnly() = assemble(address, null) + + companion object { + const val TAG_NAME = "a" + + @JvmStatic + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun isTagged( + tag: Array, + addressId: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == addressId + + @JvmStatic + fun isTagged( + tag: Array, + address: CommunityTag, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == address.toTag() + + @JvmStatic + fun isIn( + tag: Array, + addressIds: Set, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in addressIds + + @JvmStatic + fun isTaggedWithKind( + tag: Array, + kind: String, + ) = tag.has(1) && tag[0] == TAG_NAME && Address.isOfKind(tag[1], kind) + + @JvmStatic + fun parse( + aTagId: String, + relay: String?, + ) = Address.parse(aTagId)?.let { + CommunityTag(it, relay?.let { RelayUrlNormalizer.normalizeOrNull(it) }) + } + + @JvmStatic + fun parse(tag: Array): CommunityTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return parse(tag[1], tag.getOrNull(2)) + } + + @JvmStatic + fun parseValidAddress(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return Address.parse(tag[1])?.toValue() + } + + @JvmStatic + fun parseAddress(tag: Array): Address? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return Address.parse(tag[1]) + } + + @JvmStatic + fun parseAddressId(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + @JvmStatic + fun parseAsHint(tag: Array): AddressHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + ensure(tag[1].contains(':')) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(relayHint != null) { return null } + + return AddressHint(tag[1], relayHint) + } + + @JvmStatic + fun assemble( + aTagId: HexKey, + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, aTagId, relay?.url) + + @JvmStatic + fun assemble( + address: Address, + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, address.toValue(), relay?.url) + + @JvmStatic + fun assemble( + kind: Int, + pubKey: String, + dTag: String, + relay: NormalizedRelayUrl?, + ) = assemble(Address.assemble(kind, pubKey, dTag), relay) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/MovieId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/CommentEventExt.kt similarity index 66% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/MovieId.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/CommentEventExt.kt index be8e316fce..77167578f5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/MovieId.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/CommentEventExt.kt @@ -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,22 +20,20 @@ */ package com.vitorpamplona.quartz.nip73ExternalIds -import kotlin.math.min +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip22Comments.tags.RootIdentifierTag +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId -class MovieId( - val isan: String, - val hint: String? = null, -) : ExternalId { - override fun toScope() = toScope(isan) +fun CommentEvent.scopes() = tags.mapNotNull { RootIdentifierTag.parseExternalId(it) } - override fun toKind() = toKind(isan) +fun CommentEvent.scope(): ExternalId? { + val scopes = scopes() - override fun hint() = hint + if (scopes.isEmpty()) return null - companion object { - // "isan:0000-0000-401A-0000-7" - fun toScope(isan: String) = "isan:" + isan.lowercase().substring(0, min(21, isan.length)) - - fun toKind(isan: String) = "isan" + return if (scopes.first() is GeohashId) { + scopes.maxByOrNull { if (it is GeohashId) it.geohash else "" } + } else { + scopes().first() } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/ExternalId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/ExternalId.kt index 83acbeca67..34d64c545f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/ExternalId.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/ExternalId.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/books/BookId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/books/BookId.kt new file mode 100644 index 0000000000..f0fdf4f13b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/books/BookId.kt @@ -0,0 +1,66 @@ +/** + * 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.quartz.nip73ExternalIds.books + +import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId +import com.vitorpamplona.quartz.utils.ensure + +class BookId( + val isbn: String, + val hint: String? = null, +) : ExternalId { + override fun toScope() = toScope(isbn) + + override fun toKind() = toKind(isbn) + + override fun hint() = hint + + companion object { + const val KIND = "isbn" + const val PREFIX_COLON = "isbn:" + + // "isbn:9780765382030" + fun toScope(isbn: String) = PREFIX_COLON + isbn.lowercase().replace("-", "") + + fun toKind(isbn: String) = KIND + + fun match( + encoded: String, + value: String, + ): Boolean { + ensure(encoded.startsWith(PREFIX_COLON)) { return false } + return encoded.indexOf(value, PREFIX_COLON.length) > 0 + } + + fun parse(encoded: String): String? { + ensure(encoded.startsWith(PREFIX_COLON)) { return null } + return encoded.substring(PREFIX_COLON.length) + } + + fun parse( + encoded: String, + hint: String?, + ): BookId? { + ensure(encoded.startsWith(PREFIX_COLON)) { return null } + return BookId(encoded.substring(PREFIX_COLON.length), hint) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/books/CommentEventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/books/CommentEventExt.kt new file mode 100644 index 0000000000..2a8eadd915 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/books/CommentEventExt.kt @@ -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.quartz.nip73ExternalIds.books + +import com.vitorpamplona.quartz.nip22Comments.CommentEvent + +fun CommentEvent.isBookScoped() = hasScopeKind(BookId.KIND) + +fun CommentEvent.bookScope() = firstScopeValue(BookId::parse) + +fun CommentEvent.bookScopes() = scopeValues(BookId::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/location/CommentEventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/location/CommentEventExt.kt new file mode 100644 index 0000000000..7ca7b6e923 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/location/CommentEventExt.kt @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip73ExternalIds.location + +import com.vitorpamplona.quartz.nip22Comments.CommentEvent + +fun CommentEvent.isGeohashedScoped() = hasScopeKind(GeohashId.KIND) + +fun CommentEvent.geohashedScope() = scopeValues(GeohashId::parse).maxByOrNull { it.length } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/location/GeohashId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/location/GeohashId.kt new file mode 100644 index 0000000000..10a354623c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/location/GeohashId.kt @@ -0,0 +1,65 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip73ExternalIds.location + +import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId +import com.vitorpamplona.quartz.utils.ensure + +class GeohashId( + val geohash: String, + val hint: String? = null, +) : ExternalId { + override fun toScope() = toScope(geohash) + + override fun toKind() = toKind(geohash) + + override fun hint() = hint + + companion object { + const val KIND = "geo" + const val PREFIX_COLON = "geo:" + + fun toScope(geohash: String) = PREFIX_COLON + geohash.lowercase() + + fun toKind(geohash: String) = KIND + + fun match( + encoded: String, + value: String, + ): Boolean { + ensure(encoded.startsWith(PREFIX_COLON)) { return false } + return encoded.indexOf(value, PREFIX_COLON.length) > 0 + } + + fun parse(encoded: String): String? { + ensure(encoded.startsWith(PREFIX_COLON)) { return null } + return encoded.substring(PREFIX_COLON.length) + } + + fun parse( + encoded: String, + hint: String?, + ): GeohashId? { + ensure(encoded.startsWith(PREFIX_COLON)) { return null } + return GeohashId(encoded.substring(PREFIX_COLON.length), hint) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/movies/CommentEventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/movies/CommentEventExt.kt new file mode 100644 index 0000000000..e7af0eee8a --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/movies/CommentEventExt.kt @@ -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.quartz.nip73ExternalIds.movies + +import com.vitorpamplona.quartz.nip22Comments.CommentEvent + +fun CommentEvent.isMovieScoped() = hasScopeKind(MovieId.KIND) + +fun CommentEvent.movieScope() = firstScopeValue(MovieId::parse) + +fun CommentEvent.movieScopes() = scopeValues(MovieId::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/movies/MovieId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/movies/MovieId.kt new file mode 100644 index 0000000000..580379a3d2 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/movies/MovieId.kt @@ -0,0 +1,72 @@ +/** + * 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.quartz.nip73ExternalIds.movies + +import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId +import com.vitorpamplona.quartz.utils.ensure +import kotlin.math.min + +class MovieId( + val isan: String, + val hint: String? = null, +) : ExternalId { + override fun toScope() = toScope(isan) + + override fun toKind() = toKind(isan) + + override fun hint() = hint + + companion object { + const val KIND = "isan" + const val PREFIX_COLON = "isan:" + + // "isan:0000-0000-401A-0000-7" + fun toScope(isan: String) = + PREFIX_COLON + + isan.lowercase().substring( + 0, + min(21, isan.length), + ) + + fun toKind(isan: String) = KIND + + fun match( + encoded: String, + value: String, + ): Boolean { + ensure(encoded.startsWith(PREFIX_COLON)) { return false } + return encoded.indexOf(value, PREFIX_COLON.length) > 0 + } + + fun parse(encoded: String): String? { + ensure(encoded.startsWith(PREFIX_COLON)) { return null } + return encoded.substring(PREFIX_COLON.length) + } + + fun parse( + encoded: String, + hint: String?, + ): MovieId? { + ensure(encoded.startsWith(PREFIX_COLON)) { return null } + return MovieId(encoded.substring(PREFIX_COLON.length), hint) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/papers/CommentEventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/papers/CommentEventExt.kt new file mode 100644 index 0000000000..d00d1793df --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/papers/CommentEventExt.kt @@ -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.quartz.nip73ExternalIds.papers + +import com.vitorpamplona.quartz.nip22Comments.CommentEvent + +fun CommentEvent.isPaperScoped() = hasScopeKind(PaperId.KIND) + +fun CommentEvent.paperScope() = firstScopeValue(PaperId::parse) + +fun CommentEvent.paperScopes() = scopeValues(PaperId::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/papers/PaperId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/papers/PaperId.kt new file mode 100644 index 0000000000..633bcb7bfe --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/papers/PaperId.kt @@ -0,0 +1,66 @@ +/** + * 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.quartz.nip73ExternalIds.papers + +import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId +import com.vitorpamplona.quartz.utils.ensure + +class PaperId( + val doi: String, + val hint: String? = null, +) : ExternalId { + override fun toScope() = toScope(doi) + + override fun toKind() = toKind(doi) + + override fun hint() = hint + + companion object { + const val KIND = "doi" + const val PREFIX_COLON = "doi:" + + // "doi:10.1000/182" + fun toScope(doi: String) = PREFIX_COLON + doi.lowercase() + + fun toKind(doi: String) = KIND + + fun match( + encoded: String, + value: String, + ): Boolean { + ensure(encoded.startsWith(PREFIX_COLON)) { return false } + return encoded.indexOf(value, PREFIX_COLON.length) > 0 + } + + fun parse(encoded: String): String? { + ensure(encoded.startsWith(PREFIX_COLON)) { return null } + return encoded.substring(PREFIX_COLON.length) + } + + fun parse( + encoded: String, + hint: String?, + ): PaperId? { + ensure(encoded.startsWith(PREFIX_COLON)) { return null } + return PaperId(encoded.substring(PREFIX_COLON.length), hint) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/CommentEventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/CommentEventExt.kt new file mode 100644 index 0000000000..46f3e1415b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/CommentEventExt.kt @@ -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.quartz.nip73ExternalIds.podcasts + +import com.vitorpamplona.quartz.nip22Comments.CommentEvent + +fun CommentEvent.isPodcastEpisodeScoped() = hasScopeKind(PodcastEpisodeId.KIND) + +fun CommentEvent.podcastEpisodeScope() = firstScopeValue(PodcastEpisodeId::parse) + +fun CommentEvent.podcastEpisodeScopes() = scopeValues(PodcastEpisodeId::parse) + +fun CommentEvent.isPodcastFeedScoped() = hasScopeKind(PodcastFeedId.KIND) + +fun CommentEvent.podcastFeedScope() = firstScopeValue(PodcastFeedId::parse) + +fun CommentEvent.podcastFeedScopes() = scopeValues(PodcastFeedId::parse) + +fun CommentEvent.isPodcastPublisherScoped() = hasScopeKind(PodcastPublisherId.KIND) + +fun CommentEvent.podcastPublisherScope() = firstScopeValue(PodcastPublisherId::parse) + +fun CommentEvent.podcastPublisherScopes() = scopeValues(PodcastPublisherId::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastEpisodeId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastEpisodeId.kt new file mode 100644 index 0000000000..df0b00014d --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastEpisodeId.kt @@ -0,0 +1,65 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip73ExternalIds.podcasts + +import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId +import com.vitorpamplona.quartz.utils.ensure + +class PodcastEpisodeId( + val guid: String, + val hint: String? = null, +) : ExternalId { + override fun toScope() = toScope(guid) + + override fun toKind() = toKind(guid) + + override fun hint() = hint + + companion object { + const val KIND = "podcast:item:guid:guid" + const val PREFIX_COLON = "podcast:item:guid:" + + fun toScope(guid: String) = PREFIX_COLON + guid + + fun toKind(guid: String) = KIND + + fun match( + encoded: String, + value: String, + ): Boolean { + ensure(encoded.startsWith(PREFIX_COLON)) { return false } + return encoded.indexOf(value, PREFIX_COLON.length) > 0 + } + + fun parse(encoded: String): String? { + ensure(encoded.startsWith(PREFIX_COLON)) { return null } + return encoded.substring(PREFIX_COLON.length) + } + + fun parse( + encoded: String, + hint: String?, + ): PodcastEpisodeId? { + ensure(encoded.startsWith(PREFIX_COLON)) { return null } + return PodcastEpisodeId(encoded.substring(PREFIX_COLON.length), hint) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PodcastEpisodeId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastFeedId.kt similarity index 54% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PodcastEpisodeId.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastFeedId.kt index 7ce70ae29b..a63c794f35 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PodcastEpisodeId.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastFeedId.kt @@ -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,9 +18,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip73ExternalIds +package com.vitorpamplona.quartz.nip73ExternalIds.podcasts -class PodcastEpisodeId( +import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId +import com.vitorpamplona.quartz.utils.ensure + +class PodcastFeedId( val guid: String, val hint: String? = null, ) : ExternalId { @@ -31,9 +34,33 @@ class PodcastEpisodeId( override fun hint() = hint companion object { - // "isbn:9780765382030" - fun toScope(guid: String) = "podcast:item:guid:" + guid + const val KIND = "podcast:guid" + const val PREFIX_COLON = "podcast:guid:" - fun toKind(guid: String) = "podcast:item:guid" + // "isbn:9780765382030" + fun toScope(guid: String) = PREFIX_COLON + guid + + fun toKind(guid: String) = KIND + + fun match( + encoded: String, + value: String, + ): Boolean { + ensure(encoded.startsWith(PREFIX_COLON)) { return false } + return encoded.indexOf(value, PREFIX_COLON.length) > 0 + } + + fun parse(encoded: String): String? { + ensure(encoded.startsWith(PREFIX_COLON)) { return null } + return encoded.substring(PREFIX_COLON.length) + } + + fun parse( + encoded: String, + hint: String?, + ): PodcastFeedId? { + ensure(encoded.startsWith(PREFIX_COLON)) { return null } + return PodcastFeedId(encoded.substring(PREFIX_COLON.length), hint) + } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PodcastPublisherId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastPublisherId.kt similarity index 55% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PodcastPublisherId.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastPublisherId.kt index 77d7fbbb7b..e2ebc1b01d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PodcastPublisherId.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastPublisherId.kt @@ -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,7 +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.quartz.nip73ExternalIds +package com.vitorpamplona.quartz.nip73ExternalIds.podcasts + +import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId +import com.vitorpamplona.quartz.utils.ensure class PodcastPublisherId( val guid: String, @@ -31,9 +34,33 @@ class PodcastPublisherId( override fun hint() = hint companion object { - // "isbn:9780765382030" - fun toScope(guid: String) = "podcast:publisher:guid:" + guid + const val KIND = "podcast:publisher:guid" + const val PREFIX_COLON = "podcast:publisher:guid:" - fun toKind(guid: String) = "podcast:publisher:guid" + // "isbn:9780765382030" + fun toScope(guid: String) = PREFIX_COLON + guid + + fun toKind(guid: String) = KIND + + fun match( + encoded: String, + value: String, + ): Boolean { + ensure(encoded.startsWith(PREFIX_COLON)) { return false } + return encoded.indexOf(value, PREFIX_COLON.length) > 0 + } + + fun parse(encoded: String): String? { + ensure(encoded.startsWith(PREFIX_COLON)) { return null } + return encoded.substring(PREFIX_COLON.length) + } + + fun parse( + encoded: String, + hint: String?, + ): PodcastPublisherId? { + ensure(encoded.startsWith(PREFIX_COLON)) { return null } + return PodcastPublisherId(encoded.substring(PREFIX_COLON.length), hint) + } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/topics/CommentEventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/topics/CommentEventExt.kt new file mode 100644 index 0000000000..2c438c3ef5 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/topics/CommentEventExt.kt @@ -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.quartz.nip73ExternalIds.topics + +import com.vitorpamplona.quartz.nip22Comments.CommentEvent + +fun CommentEvent.isHashtagScoped() = hasScopeKind(HashtagId.KIND) + +fun CommentEvent.hashtagScope() = firstScopeValue(HashtagId::parse) + +fun CommentEvent.hashtagScopes() = scopeValues(HashtagId::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/topics/HashtagId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/topics/HashtagId.kt new file mode 100644 index 0000000000..1198100ae3 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/topics/HashtagId.kt @@ -0,0 +1,70 @@ +/** + * 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.quartz.nip73ExternalIds.topics + +import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId +import com.vitorpamplona.quartz.utils.ensure + +class HashtagId( + val topic: String, + val hint: String? = null, +) : ExternalId { + override fun toScope() = toScope(topic) + + override fun toKind() = toKind(topic) + + override fun hint() = hint + + companion object { + const val KIND = "#" + const val KIND_CHR = '#' + + fun toScope(topic: String) = KIND + topic.lowercase() + + fun toKind(topic: String) = KIND + + fun match( + encoded: String, + value: String, + ): Boolean { + ensure(encoded.length > 1) { return false } + ensure(encoded[0] == KIND_CHR) { return false } + return encoded.indexOf(value, 1) > 0 + } + + fun parse(encoded: String): String? = + if (encoded.length > 1 && encoded[0] == KIND_CHR) { + encoded.substring(1) + } else { + null + } + + fun parse( + encoded: String, + hint: String?, + ): HashtagId? = + if (encoded.length > 1 && encoded[0] == KIND_CHR) { + HashtagId(encoded.substring(1), hint) + } else { + null + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/urls/CommentEventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/urls/CommentEventExt.kt new file mode 100644 index 0000000000..1137ff93c6 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/urls/CommentEventExt.kt @@ -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.quartz.nip73ExternalIds.urls + +import com.vitorpamplona.quartz.nip22Comments.CommentEvent + +fun CommentEvent.isUrlScoped() = hasScopeKind(UrlId.KIND) + +fun CommentEvent.urlScope() = firstScopeValue(UrlId::parse) + +fun CommentEvent.urlScopes() = scopeValues(UrlId::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/urls/UrlId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/urls/UrlId.kt new file mode 100644 index 0000000000..9b51ac7856 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/urls/UrlId.kt @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip73ExternalIds.urls + +import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId +import com.vitorpamplona.quartz.utils.ensure +import com.vitorpamplona.quartz.utils.toStringNoFragment +import org.czeal.rfc3986.URIReference + +class UrlId( + val url: String, + val hint: String? = null, +) : ExternalId { + override fun toScope() = toScope(url) + + override fun toKind() = toKind(url) + + override fun hint() = hint + + companion object { + const val KIND = "web" + const val PREFIX1 = "https://" + const val PREFIX2 = "http://" + + fun toScope(url: String) = URIReference.parse(url).normalize().toStringNoFragment() + + fun toKind(url: String) = KIND + + fun match( + encoded: String, + value: String, + ): Boolean { + ensure(encoded.startsWith(PREFIX1) || encoded.startsWith(PREFIX2)) { return false } + return encoded == value + } + + fun parse(encoded: String): String? { + ensure(encoded.startsWith(PREFIX1) || encoded.startsWith(PREFIX2)) { return null } + return encoded + } + + fun parse( + encoded: String, + hint: String?, + ): UrlId? { + ensure(encoded.startsWith(PREFIX1) || encoded.startsWith(PREFIX2)) { return null } + return UrlId(encoded, hint) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/GoalEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/GoalEvent.kt index e50abb413d..293ecde409 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/GoalEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/GoalEvent.kt @@ -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,9 +26,15 @@ import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.references.reference import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag @@ -46,7 +52,22 @@ class GoalEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + fun topics() = hashtags() fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/TagArrayBuilderExt.kt index 8cb28f0d38..8d9595d3b3 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/tags/AmountTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/tags/AmountTag.kt index 177c985ab6..d20fd89934 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/tags/AmountTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/tags/AmountTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/tags/ClosedAtTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/tags/ClosedAtTag.kt index 2af7ec8d6f..2f3c4efcab 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/tags/ClosedAtTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/tags/ClosedAtTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/tags/RelayListTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/tags/RelayListTag.kt index 9f49ce7611..da4c72540d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/tags/RelayListTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/tags/RelayListTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataEvent.kt index aaa0a86e60..6dcd9a31eb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataEvent.kt @@ -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 @@ -52,14 +52,13 @@ class AppSpecificDataEvent( dTag: String, ): ATag = ATag(KIND, pubkey, dTag, null) - fun create( + suspend fun create( dTag: String, description: String, otherTags: Array>, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (AppSpecificDataEvent) -> Unit, - ) { + ): AppSpecificDataEvent { val withD = if (otherTags.any { it.size > 1 && it[0] == "d" && it[1] == dTag }) { otherTags @@ -74,7 +73,7 @@ class AppSpecificDataEvent( withD } - signer.sign(createdAt, KIND, newTags, description, onReady) + return signer.sign(createdAt, KIND, newTags, description) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt index b200a51135..b1be670b95 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt @@ -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,15 +22,33 @@ package com.vitorpamplona.quartz.nip84Highlights import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.firstTaggedATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.firstTaggedAddress +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.events.firstTaggedEvent +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.firstTaggedUser import com.vitorpamplona.quartz.nip01Core.tags.references.ReferenceTag import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip19Bech32.addressHints +import com.vitorpamplona.quartz.nip19Bech32.addressIds +import com.vitorpamplona.quartz.nip19Bech32.eventHints +import com.vitorpamplona.quartz.nip19Bech32.eventIds +import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints +import com.vitorpamplona.quartz.nip19Bech32.pubKeys import com.vitorpamplona.quartz.nip22Comments.RootScope import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip50Search.SearchableEvent +import com.vitorpamplona.quartz.nip84Highlights.tags.CommentTag import com.vitorpamplona.quartz.nip84Highlights.tags.ContextTag import com.vitorpamplona.quartz.utils.TimeUtils @@ -43,13 +61,67 @@ class HighlightEvent( content: String, sig: HexKey, ) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig), - RootScope { + RootScope, + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider, + SearchableEvent { + override fun indexableContent() = "comment: " + comment() + "\ncontext: " + context() + "\n" + content + + override fun eventHints(): List { + val eHints = tags.mapNotNull(ETag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseEventAsHint) + val nip19Hints = citedNIP19().eventHints() + + return eHints + qHints + nip19Hints + } + + override fun linkedEventIds(): List { + val eHints = tags.mapNotNull(ETag::parseId) + val qHints = tags.mapNotNull(QTag::parseEventId) + val nip19Hints = citedNIP19().eventIds() + + return eHints + qHints + nip19Hints + } + + override fun addressHints(): List { + val aHints = tags.mapNotNull(ATag::parseAsHint) + val qHints = tags.mapNotNull(QTag::parseAddressAsHint) + val nip19Hints = citedNIP19().addressHints() + + return aHints + qHints + nip19Hints + } + + override fun linkedAddressIds(): List { + val aHints = tags.mapNotNull(ATag::parseAddressId) + val qHints = tags.mapNotNull(QTag::parseAddressId) + val nip19Hints = citedNIP19().addressIds() + + return aHints + qHints + nip19Hints + } + + override fun pubKeyHints(): List { + val pHints = tags.mapNotNull(PTag::parseAsHint) + val nip19Hints = citedNIP19().pubKeyHints() + + return pHints + nip19Hints + } + + override fun linkedPubKeys(): List { + val pHints = tags.mapNotNull(PTag::parseKey) + val nip19Hints = citedNIP19().pubKeys() + + return pHints + nip19Hints + } + fun inUrl() = tags.firstNotNullOfOrNull(ReferenceTag::parse) fun author() = firstTaggedUser() fun quote() = content + fun comment() = tags.firstNotNullOfOrNull(CommentTag::parse) + fun context() = tags.firstNotNullOfOrNull(ContextTag::parse) fun inPost() = firstTaggedATag() @@ -62,13 +134,10 @@ class HighlightEvent( const val KIND = 9802 const val ALT = "Highlight/quote event" - fun create( + suspend fun create( msg: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (HighlightEvent) -> Unit, - ) { - signer.sign(createdAt, KIND, arrayOf(AltTag.assemble(ALT)), msg, onReady) - } + ): HighlightEvent = signer.sign(createdAt, KIND, arrayOf(AltTag.assemble(ALT)), msg) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip84Highlights/tags/CommentTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip84Highlights/tags/CommentTag.kt new file mode 100644 index 0000000000..63598db3de --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip84Highlights/tags/CommentTag.kt @@ -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.quartz.nip84Highlights.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class CommentTag { + companion object { + const val TAG_NAME = "comment" + + @JvmStatic + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + @JvmStatic + fun assemble(context: String) = arrayOf(TAG_NAME, context) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip84Highlights/tags/ContextTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip84Highlights/tags/ContextTag.kt index 80daa368fd..5468c1976d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip84Highlights/tags/ContextTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip84Highlights/tags/ContextTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/PlatformType.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/PlatformType.kt index 567018380d..fa5f1639dc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/PlatformType.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/PlatformType.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/ClientTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/ClientTag.kt new file mode 100644 index 0000000000..2810fe19ef --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/ClientTag.kt @@ -0,0 +1,77 @@ +/** + * 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.quartz.nip89AppHandlers.clientTag + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip46RemoteSigner.getOrNull +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +class ClientTag( + val name: String, + val address: Address?, + val relayHint: NormalizedRelayUrl?, +) { + fun toTagArray() = assemble(name, address, relayHint) + + companion object { + const val TAG_NAME = "client" + + @JvmStatic + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun parse(tag: Array): ClientTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + val address = tag.getOrNull(2)?.let { Address.parse(it) } + val relayHint = tag.getOrNull(3)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return ClientTag( + tag[1], + address, + relayHint, + ) + } + + @JvmStatic + fun assemble(name: String) = arrayOfNotNull(TAG_NAME, name) + + @JvmStatic + fun assemble( + name: String, + address: String? = null, + relayHint: NormalizedRelayUrl? = null, + ) = arrayOfNotNull(TAG_NAME, name, address, relayHint?.url) + + @JvmStatic + fun assemble( + name: String, + address: Address? = null, + relayHint: NormalizedRelayUrl? = null, + ) = assemble(name, address?.toValue(), relayHint) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/EventExt.kt new file mode 100644 index 0000000000..81b92c0393 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/EventExt.kt @@ -0,0 +1,26 @@ +/** + * 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.quartz.nip89AppHandlers.clientTag + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount + +fun Event.client() = tags.zapraiserAmount() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/SubscriptionCollection.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayBuilderExt.kt similarity index 70% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/SubscriptionCollection.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayBuilderExt.kt index 3f2346bdde..566492c985 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/SubscriptionCollection.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayBuilderExt.kt @@ -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,20 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core.relay +package com.vitorpamplona.quartz.nip89AppHandlers.clientTag import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint -interface SubscriptionCollection { - fun isActive(subscriptionId: String): Boolean +fun TagArrayBuilder.client(name: String) = addUnique(ClientTag.assemble(name)) - fun getFilters(subscriptionId: String): List - - fun allSubscriptions(): List - - fun match( - subscriptionId: String, - event: Event, - ): Boolean -} +fun TagArrayBuilder.client( + name: String, + address: AddressHint, +) = addUnique(ClientTag.assemble(name, address.addressId, address.relay)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayExt.kt new file mode 100644 index 0000000000..3516fad880 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayExt.kt @@ -0,0 +1,25 @@ +/** + * 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.quartz.nip89AppHandlers.clientTag + +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.client() = this.mapNotNull(ClientTag::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEvent.kt index d1836cfe7b..f2d77522ea 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEvent.kt @@ -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 @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip89AppHandlers.PlatformType import com.vitorpamplona.quartz.nip89AppHandlers.definition.tags.PlatformLinkTag import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CancellationException import java.util.UUID @Immutable @@ -55,12 +56,19 @@ class AppDefinitionEvent( cachedMetadata } else { try { - val newMetadata = AppMetadata.parse(content) - cachedMetadata = newMetadata - newMetadata + if (content.startsWith("{")) { + val newMetadata = AppMetadata.parse(content) + cachedMetadata = newMetadata + newMetadata + } else { + val newMetadata = AppMetadata() + newMetadata.name = content + cachedMetadata = newMetadata + newMetadata + } } catch (e: Exception) { - e.printStackTrace() - Log.w("AppDefinitionEvent", "Content Parse Error: ${toNostrUri()} ${e.localizedMessage}") + if (e is CancellationException) throw e + Log.w("AppDefinitionEvent", "Content Parse Error: ${toNostrUri()} ${e.localizedMessage}", e) null } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppMetadata.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppMetadata.kt index af2362174d..2857593eb6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppMetadata.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppMetadata.kt @@ -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,7 +22,7 @@ package com.vitorpamplona.quartz.nip89AppHandlers.definition import androidx.compose.runtime.Stable import com.fasterxml.jackson.annotation.JsonProperty -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.pointerSizeInBytes @@ -113,8 +113,8 @@ class AppMetadata { fun toJson() = assemble(this) companion object { - fun assemble(data: AppMetadata) = EventMapper.mapper.writeValueAsString(data) + fun assemble(data: AppMetadata) = JsonMapper.mapper.writeValueAsString(data) - fun parse(content: String) = EventMapper.mapper.readValue(content, AppMetadata::class.java) + fun parse(content: String) = JsonMapper.mapper.readValue(content, AppMetadata::class.java) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/EntityType.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/EntityType.kt index d3ae3cfb84..1c590cffa8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/EntityType.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/EntityType.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/TagArrayBuilderExt.kt index d5b2d20db8..1862893db6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/tags/PlatformLinkTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/tags/PlatformLinkTag.kt index 8c18bf4802..d2278feef3 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/tags/PlatformLinkTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/tags/PlatformLinkTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/AppRecommendationEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/AppRecommendationEvent.kt index 4b98181e7f..04291e4dfa 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/AppRecommendationEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/AppRecommendationEvent.kt @@ -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,6 +24,8 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag import com.vitorpamplona.quartz.nip31Alts.alt @@ -40,7 +42,12 @@ class AppRecommendationEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + AddressHintProvider { + override fun addressHints() = tags.mapNotNull(RecommendationTag::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(RecommendationTag::parseAddressId) + fun recommendations() = tags.mapNotNull(RecommendationTag::parse) fun recommendationAddresses() = tags.mapNotNull(RecommendationTag::parseAddressId) @@ -51,7 +58,7 @@ class AppRecommendationEvent( class AppRecommendationItem( val appDefinitionEvent: AppDefinitionEvent, - val relayHint: String?, + val relayHint: NormalizedRelayUrl?, val platform: PlatformType, ) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/TagArrayBuilderExt.kt index 7df2ad8b01..96122fd363 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/TagArrayBuilderExt.kt @@ -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,18 +21,19 @@ package com.vitorpamplona.quartz.nip89AppHandlers.recommendation import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.tags.RecommendationTag fun TagArrayBuilder.recommend( addressId: String, - relay: String?, + relay: NormalizedRelayUrl?, platform: String?, ) = add(RecommendationTag.assemble(addressId, relay, platform)) fun TagArrayBuilder.recommend( address: Address, - relay: String?, + relay: NormalizedRelayUrl?, platform: String?, ) = add(RecommendationTag.assemble(address, relay, platform)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/tags/RecommendationTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/tags/RecommendationTag.kt index 22c8fea763..4056e736eb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/tags/RecommendationTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/tags/RecommendationTag.kt @@ -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,6 +23,10 @@ package com.vitorpamplona.quartz.nip89AppHandlers.recommendation.tags import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -30,7 +34,7 @@ import com.vitorpamplona.quartz.utils.ensure @Immutable class RecommendationTag( val address: Address, - val relay: String? = null, + val relay: NormalizedRelayUrl? = null, val platform: String? = null, ) { fun toTagArray() = assemble(address, relay, platform) @@ -47,7 +51,8 @@ class RecommendationTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } val address = Address.parse(tag[1]) ?: return null - return RecommendationTag(address, tag.getOrNull(2), tag.getOrNull(3)) + val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + return RecommendationTag(address, relayHint, tag.getOrNull(3)) } @JvmStatic @@ -58,18 +63,31 @@ class RecommendationTag( return tag[1] } + @JvmStatic + fun parseAsHint(tag: Array): AddressHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == ATag.Companion.TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + ensure(tag[1].contains(':')) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) ?: return null + + return AddressHint(tag[1], relayHint) + } + @JvmStatic fun assemble( addressId: String, - relay: String?, + relay: NormalizedRelayUrl?, platform: String?, - ) = arrayOfNotNull(TAG_NAME, addressId, relay, platform) + ) = arrayOfNotNull(TAG_NAME, addressId, relay?.url, platform) @JvmStatic fun assemble( address: Address, - relay: String?, + relay: NormalizedRelayUrl?, platform: String?, - ) = arrayOfNotNull(TAG_NAME, address.toValue(), relay, platform) + ) = arrayOfNotNull(TAG_NAME, address.toValue(), relay?.url, platform) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryRequestEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryRequestEvent.kt index 565f9498ec..addd3c39c9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryRequestEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryRequestEvent.kt @@ -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,6 +24,7 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils @@ -42,22 +43,21 @@ class NIP90ContentDiscoveryRequestEvent( const val KIND = 5300 const val ALT = "NIP90 Content Discovery request" - fun create( + suspend fun create( dvmPublicKey: HexKey, forUser: HexKey, - relays: Set, + relays: Set, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (NIP90ContentDiscoveryRequestEvent) -> Unit, - ) { + ): NIP90ContentDiscoveryRequestEvent { val content = "" val tags = mutableListOf>() tags.add(arrayOf("p", dvmPublicKey)) tags.add(AltTag.assemble(ALT)) - tags.add(arrayOf("relays") + relays.toTypedArray()) + tags.add(arrayOf("relays") + relays.map { it.url }.toTypedArray()) tags.add(arrayOf("param", "max_results", "200")) tags.add(arrayOf("param", "user", forUser)) - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + return signer.sign(createdAt, KIND, tags.toTypedArray(), content) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryResponseEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryResponseEvent.kt index 9d182402e7..d7ca0d2ac7 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryResponseEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryResponseEvent.kt @@ -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,10 +25,9 @@ import androidx.compose.runtime.Immutable import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip31Alts.AltTag -import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.pointerSizeInBytes @@ -59,7 +58,7 @@ class NIP90ContentDiscoveryResponseEvent( try { events = - EventMapper.mapper.readValue>>(content).mapNotNull { + JsonMapper.mapper.readValue>>(content).mapNotNull { if (it.size > 1 && it[0] == "e") { it[1] } else { @@ -77,16 +76,15 @@ class NIP90ContentDiscoveryResponseEvent( const val KIND = 6300 const val ALT = "NIP90 Content Discovery reply" - fun create( + suspend fun create( signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (AppRecommendationEvent) -> Unit, - ) { + ): NIP90ContentDiscoveryResponseEvent { val tags = arrayOf( AltTag.assemble(ALT), ) - signer.sign(createdAt, KIND, tags, "", onReady) + return signer.sign(createdAt, KIND, tags, "") } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90StatusEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90StatusEvent.kt index cfda406eab..a36411df7f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90StatusEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90StatusEvent.kt @@ -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,7 +25,6 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip31Alts.AltTag -import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -78,16 +77,15 @@ class NIP90StatusEvent( const val KIND = 7000 const val ALT = "NIP90 Status update" - fun create( + suspend fun create( signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (AppRecommendationEvent) -> Unit, - ) { + ): NIP90StatusEvent { val tags = arrayOf( AltTag.assemble(ALT), ) - signer.sign(createdAt, KIND, tags, "", onReady) + return signer.sign(createdAt, KIND, tags, "") } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90UserDiscoveryRequestEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90UserDiscoveryRequestEvent.kt index d45c453409..6d4cf85e5e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90UserDiscoveryRequestEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90UserDiscoveryRequestEvent.kt @@ -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,7 +25,6 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip31Alts.AltTag -import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -41,16 +40,15 @@ class NIP90UserDiscoveryRequestEvent( const val KIND = 5301 const val ALT = "NIP90 Content Discovery request" - fun create( + suspend fun create( signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (AppRecommendationEvent) -> Unit, - ) { + ): NIP90UserDiscoveryRequestEvent { val tags = arrayOf( AltTag.assemble(ALT), ) - signer.sign(createdAt, KIND, tags, "", onReady) + return signer.sign(createdAt, KIND, tags, "") } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90UserDiscoveryResponseEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90UserDiscoveryResponseEvent.kt index 96a87fb619..a3e619c751 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90UserDiscoveryResponseEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90UserDiscoveryResponseEvent.kt @@ -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,7 +25,6 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip31Alts.AltTag -import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -41,16 +40,15 @@ class NIP90UserDiscoveryResponseEvent( const val KIND = 6301 const val ALT = "NIP90 Content Discovery reply" - fun create( + suspend fun create( signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (AppRecommendationEvent) -> Unit, - ) { + ): NIP90UserDiscoveryResponseEvent { val tags = arrayOf( AltTag.assemble(ALT), ) - signer.sign(createdAt, KIND, tags, "", onReady) + return signer.sign(createdAt, KIND, tags, "") } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/EventExt.kt index 7e706cd3f8..850b7204a7 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/EventExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/IMetaTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/IMetaTag.kt index 1c7d891533..237b11d6bb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/IMetaTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/IMetaTag.kt @@ -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,7 +25,7 @@ import com.vitorpamplona.quartz.utils.ensure class IMetaTag( val url: String, - val properties: Map>, + val properties: Map> = emptyMap(), ) { fun toTagArray() = arrayOf(TAG_NAME, "$ANCHOR_PROPERTY $url") + @@ -48,7 +48,7 @@ class IMetaTag( ensure(tag[1].isNotEmpty()) { return null } val allTags = parseIMeta(tag) - val url = allTags.get(ANCHOR_PROPERTY)?.firstOrNull() + val url = allTags[ANCHOR_PROPERTY]?.firstOrNull() return if (url != null) { IMetaTag(url, allTags.minus(ANCHOR_PROPERTY)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/IMetaTagBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/IMetaTagBuilder.kt index 94bd92c2a2..77a89b26db 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/IMetaTagBuilder.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/IMetaTagBuilder.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/TagArrayBuilderExt.kt index d7201ae7f9..990dda2bdd 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/TagArrayExt.kt index 27bfb56436..eaee8e5c36 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/TagArrayExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/FileHeaderEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/FileHeaderEvent.kt index d7abfef1a6..55d7376b42 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/FileHeaderEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/FileHeaderEvent.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/IMetaTagBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/IMetaTagBuilderExt.kt index 6e9d4d3ab3..17a33bc532 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/IMetaTagBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/IMetaTagBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/TagArrayBuilderExt.kt index 98f29ae70f..17bbc94b8b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/BlurhashTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/BlurhashTag.kt index 57c6469762..427a52f031 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/BlurhashTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/BlurhashTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/DimensionTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/DimensionTag.kt index 515b7c9160..3c393950e4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/DimensionTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/DimensionTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/FallbackTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/FallbackTag.kt index b00bfc43c5..153faa5e54 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/FallbackTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/FallbackTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/HashSha256Tag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/HashSha256Tag.kt index adf394c3a4..263ff5abb9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/HashSha256Tag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/HashSha256Tag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/ImageTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/ImageTag.kt index f8423bd3e1..fe55ccab79 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/ImageTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/ImageTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/MagnetTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/MagnetTag.kt index d608a6f641..3f0ae6d8a8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/MagnetTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/MagnetTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/MimeTypeTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/MimeTypeTag.kt index c0555f089c..6c6bd7deb1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/MimeTypeTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/MimeTypeTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/OriginalHashTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/OriginalHashTag.kt index f17d01e754..cf07ae1f31 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/OriginalHashTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/OriginalHashTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/ServiceTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/ServiceTag.kt index 18c6a9315c..2c7188b0b5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/ServiceTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/ServiceTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/SizeTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/SizeTag.kt index 645a77d1fe..cda211d95d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/SizeTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/SizeTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/SummaryTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/SummaryTag.kt index b0a405fca2..6a30162c47 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/SummaryTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/SummaryTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/ThumbTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/ThumbTag.kt index 52e53e2020..315357aaaf 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/ThumbTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/ThumbTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/TorrentInfoHash.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/TorrentInfoHash.kt index 4ea1a7d4b6..8109846741 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/TorrentInfoHash.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/TorrentInfoHash.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/UrlTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/UrlTag.kt index 05bcd0a919..0b103f8196 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/UrlTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip94FileMetadata/tags/UrlTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/HttpUrlFormatter.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/HttpUrlFormatter.kt index 72671a3d74..5854fa927a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/HttpUrlFormatter.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/HttpUrlFormatter.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/actions/DeleteResult.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/actions/DeleteResult.kt index db1101e2bb..501772b59d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/actions/DeleteResult.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/actions/DeleteResult.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/actions/UploadResult.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/actions/UploadResult.kt index 8d53e3c141..554210641a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/actions/UploadResult.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/actions/UploadResult.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/config/FileServersEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/config/FileServersEvent.kt index b7350bd49f..fabcfb31b6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/config/FileServersEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/config/FileServersEvent.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/config/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/config/TagArrayBuilderExt.kt index 7f18c9ed43..fd2f5e2990 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/config/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/config/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/config/tags/ServerTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/config/tags/ServerTag.kt index 2b926fb6eb..46df00386b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/config/tags/ServerTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/config/tags/ServerTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/info/ServerInfo.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/info/ServerInfo.kt index 0902bbdb61..b1bd838464 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/info/ServerInfo.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/info/ServerInfo.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/info/ServerInfoParser.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/info/ServerInfoParser.kt index 0f0dcb647b..d98b191826 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/info/ServerInfoParser.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip96FileStorage/info/ServerInfoParser.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/HTTPAuthorizationEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/HTTPAuthorizationEvent.kt index 08b5d27572..6fa477c6d1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/HTTPAuthorizationEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/HTTPAuthorizationEvent.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/TagArrayBuilderExt.kt index efadeb21d1..a0fcff3ced 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/TagArrayBuilderExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/tags/MethodTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/tags/MethodTag.kt index bd127fd4f8..4a18238972 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/tags/MethodTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/tags/MethodTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/tags/PayloadHashTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/tags/PayloadHashTag.kt index 1c342f8189..3f391eadcc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/tags/PayloadHashTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/tags/PayloadHashTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/tags/UrlTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/tags/UrlTag.kt index 1895606d7f..e60fdedcf1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/tags/UrlTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip98HttpAuth/tags/UrlTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/ClassifiedsEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/ClassifiedsEvent.kt index 2e62394054..0de7555310 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/ClassifiedsEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/ClassifiedsEvent.kt @@ -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 @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip92IMeta.imetas import com.vitorpamplona.quartz.nip99Classifieds.tags.ConditionTag import com.vitorpamplona.quartz.nip99Classifieds.tags.LocationTag import com.vitorpamplona.quartz.nip99Classifieds.tags.PriceTag @@ -55,6 +56,8 @@ class ClassifiedsEvent( fun condition() = tags.firstNotNullOfOrNull(ConditionTag::parse) + fun conditionValid() = tags.firstNotNullOfOrNull(ConditionTag::parseCondition) + fun images() = tags.mapNotNull(ImageTag::parse) fun status() = tags.firstNotNullOfOrNull(StatusTag::parse) @@ -71,6 +74,22 @@ class ClassifiedsEvent( fun isWellFormed() = tags.containsAllTagNamesWithValues(REQUIRED_FIELDS) + fun imageMetas(): List { + val images = images() + val imetas = imetas() + + val imetaSet = imetas.associate { it.url to it } + + return images.map { + val imeta = imetaSet.get(it) + if (imeta != null) { + ProductImageMeta.parse(imeta) + } else { + ProductImageMeta(it) + } + } + } + companion object { const val KIND = 30402 const val ALT_DESCRIPTION = "Classifieds listing" diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/ProductImageMeta.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/ProductImageMeta.kt new file mode 100644 index 0000000000..613bd9c585 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/ProductImageMeta.kt @@ -0,0 +1,67 @@ +/** + * 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.quartz.nip99Classifieds + +import com.vitorpamplona.quartz.nip68Picture.alt +import com.vitorpamplona.quartz.nip68Picture.blurhash +import com.vitorpamplona.quartz.nip68Picture.dims +import com.vitorpamplona.quartz.nip68Picture.hash +import com.vitorpamplona.quartz.nip68Picture.mimeType +import com.vitorpamplona.quartz.nip68Picture.size +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag + +data class ProductImageMeta( + val url: String, + val mimeType: String? = null, + val blurhash: String? = null, + val dimension: DimensionTag? = null, + val alt: String? = null, + val hash: String? = null, + val size: Int? = null, +) { + fun toIMeta(): IMetaTag = + IMetaTagBuilder(url) + .apply { + mimeType?.let { mimeType(it) } + alt?.let { alt(it) } + hash?.let { hash(it) } + size?.let { size(it) } + dimension?.let { dims(it) } + blurhash?.let { blurhash(it) } + }.build() + + fun toIMetaArray(): Array = toIMeta().toTagArray() + + companion object { + fun parse(iMeta: IMetaTag): ProductImageMeta = + ProductImageMeta( + iMeta.url, + iMeta.mimeType()?.firstOrNull(), + iMeta.blurhash()?.firstOrNull(), + iMeta.dims()?.firstOrNull()?.let { DimensionTag.parse(it) }, + iMeta.alt()?.firstOrNull(), + iMeta.hash()?.firstOrNull(), + iMeta.size()?.firstOrNull()?.toIntOrNull(), + ) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/TagArrayBuilderExt.kt index 2da2c86daf..4e02fae13f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/TagArrayBuilderExt.kt @@ -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 @@ -36,7 +36,7 @@ fun TagArrayBuilder.summary(summary: String) = addUnique(Summa fun TagArrayBuilder.location(location: String) = addUnique(LocationTag.assemble(location)) -fun TagArrayBuilder.image(imageUrl: String) = addUnique(ImageTag.assemble(imageUrl)) +fun TagArrayBuilder.image(imageUrl: String) = add(ImageTag.assemble(imageUrl)) fun TagArrayBuilder.images(imageUrls: List) = addAll(imageUrls.map { ImageTag.assemble(it) }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/tags/ConditionTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/tags/ConditionTag.kt index 2071cf817c..357adc7c8e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/tags/ConditionTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/tags/ConditionTag.kt @@ -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,7 +25,7 @@ import com.vitorpamplona.quartz.utils.ensure class ConditionTag { enum class CONDITION( - val value: String, + val code: String, ) { NEW("new"), USED_LIKE_NEW("like new"), @@ -34,6 +34,10 @@ class ConditionTag { ; fun toTagArray() = assemble(this) + + companion object { + fun parse(cond: String) = entries.firstOrNull { it.code == cond } + } } companion object { @@ -48,6 +52,9 @@ class ConditionTag { } @JvmStatic - fun assemble(condition: CONDITION) = arrayOf(TAG_NAME, condition.value) + fun parseCondition(tag: Array): CONDITION? = parse(tag)?.let { CONDITION.parse(it) } + + @JvmStatic + fun assemble(condition: CONDITION) = arrayOf(TAG_NAME, condition.code) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/tags/LocationTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/tags/LocationTag.kt index 1a7efcd9e3..0bc4fedeba 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/tags/LocationTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/tags/LocationTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/tags/PriceTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/tags/PriceTag.kt index e432aef23b..fb9f5319b6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/tags/PriceTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/tags/PriceTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/tags/StatusTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/tags/StatusTag.kt index 149cc9c74b..bdaf07682b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/tags/StatusTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/tags/StatusTag.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/AudioMeta.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/AudioMeta.kt new file mode 100644 index 0000000000..8d035e7206 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/AudioMeta.kt @@ -0,0 +1,54 @@ +/** + * 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.quartz.nipA0VoiceMessages + +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.WaveformTag + +data class AudioMeta( + val url: String, + val mimeType: String? = null, + val hash: String? = null, + val duration: Int? = null, + val waveform: List? = null, +) { + fun toIMetaArray(): Array = + IMetaTagBuilder(url) + .apply { + mimeType?.let { mimeType(it) } + hash?.let { hash(it) } + duration?.let { duration(it) } + waveform?.let { waveform(it) } + }.build() + .toTagArray() + + companion object { + fun parse(iMeta: IMetaTag): AudioMeta = + AudioMeta( + url = iMeta.url, + mimeType = iMeta.mimeType()?.firstOrNull(), + hash = iMeta.hash()?.firstOrNull(), + duration = iMeta.duration()?.firstOrNull()?.toIntOrNull(), + waveform = iMeta.waveform()?.firstOrNull()?.let { WaveformTag.parseWave(it) }, + ) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/RelaySetEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/BaseVoiceEvent.kt similarity index 59% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/RelaySetEvent.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/BaseVoiceEvent.kt index 49e9aae885..d6689ad5f2 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/RelaySetEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/BaseVoiceEvent.kt @@ -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,43 +18,40 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip51Lists +package com.vitorpamplona.quartz.nipA0VoiceMessages import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip92IMeta.imetas import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class RelaySetEvent( +open class BaseVoiceEvent( id: HexKey, pubKey: HexKey, createdAt: Long, + kind: Int, tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun relays() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] } - - fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1) +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + fun iMetaTags() = imetas().map { AudioMeta.parse(it) } companion object { - const val KIND = 30002 - const val ALT = "Relay list" - - fun create( - relays: List, - signer: NostrSigner, + fun build( + voiceMessage: AudioMeta, + kind: Int, + alt: String, createdAt: Long = TimeUtils.now(), - onReady: (RelaySetEvent) -> Unit, - ) { - val tags = mutableListOf>() - relays.forEach { tags.add(arrayOf("r", it)) } - tags.add(AltTag.assemble(ALT)) - - signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady) + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(kind, voiceMessage.url, createdAt) { + alt(alt) + audioIMeta(voiceMessage) + initializer() } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/IMetaTagBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/IMetaTagBuilderExt.kt new file mode 100644 index 0000000000..4562d52754 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/IMetaTagBuilderExt.kt @@ -0,0 +1,39 @@ +/** + * 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.quartz.nipA0VoiceMessages + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.DurationTag +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.HashSha256Tag +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.MimeTypeTag +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.WaveformTag + +/** + * Contains the IMeta tags that are used by Picture events. + */ +fun IMetaTagBuilder.hash(hash: HexKey) = add(HashSha256Tag.TAG_NAME, hash) + +fun IMetaTagBuilder.duration(size: Int) = add(DurationTag.TAG_NAME, size.toString()) + +fun IMetaTagBuilder.waveform(wave: List) = add(WaveformTag.TAG_NAME, WaveformTag.assembleWave(wave)) + +fun IMetaTagBuilder.mimeType(mime: String) = add(MimeTypeTag.TAG_NAME, mime) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/IMetaTagExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/IMetaTagExt.kt new file mode 100644 index 0000000000..370f830f08 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/IMetaTagExt.kt @@ -0,0 +1,35 @@ +/** + * 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.quartz.nipA0VoiceMessages + +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.DurationTag +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.HashSha256Tag +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.MimeTypeTag +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.WaveformTag + +fun IMetaTag.hash() = properties.get(HashSha256Tag.TAG_NAME) + +fun IMetaTag.duration() = properties.get(DurationTag.TAG_NAME) + +fun IMetaTag.waveform() = properties.get(WaveformTag.TAG_NAME) + +fun IMetaTag.mimeType() = properties.get(MimeTypeTag.TAG_NAME) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..f482eb2019 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/TagArrayBuilderExt.kt @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipA0VoiceMessages + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyAuthorTag +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyEventTag +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyKindTag + +fun TagArrayBuilder.audioIMeta( + url: String, + mimeType: String? = null, + hash: String? = null, + duration: Int? = null, + waveform: List? = null, +) = audioIMeta(AudioMeta(url, mimeType, hash, duration, waveform)) + +fun TagArrayBuilder.audioIMeta(imeta: AudioMeta): TagArrayBuilder { + add(imeta.toIMetaArray()) + imeta.hash?.let { add(HashSha256Tag.assemble(it)) } + return this +} + +fun TagArrayBuilder.audioIMeta(audioUrls: List): TagArrayBuilder { + audioUrls.forEach { audioIMeta(it) } + return this +} + +fun TagArrayBuilder.replyEvent( + eventId: String, + relayHint: NormalizedRelayUrl?, + pubkey: String?, +) = addUnique(ReplyEventTag.assemble(eventId, relayHint, pubkey)) + +fun TagArrayBuilder.replyKind(kind: String) = addUnique(ReplyKindTag.assemble(kind)) + +fun TagArrayBuilder.replyKind(kind: Int) = addUnique(ReplyKindTag.assemble(kind)) + +fun TagArrayBuilder.replyAuthor( + pubKey: HexKey, + relay: NormalizedRelayUrl?, +) = add(ReplyAuthorTag.assemble(pubKey, relay)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceEvent.kt new file mode 100644 index 0000000000..33f30d596f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceEvent.kt @@ -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.quartz.nipA0VoiceMessages + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class VoiceEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseVoiceEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 1222 + const val ALT_DESCRIPTION = "Voice message" + + fun build( + url: String, + mimeType: String?, + hash: String, + duration: Int, + waveform: List, + ) = build(AudioMeta(url, mimeType, hash, duration, waveform)) + + fun build( + voiceMessage: AudioMeta, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = build(voiceMessage, KIND, ALT_DESCRIPTION, createdAt, initializer) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt new file mode 100644 index 0000000000..eb7444881d --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt @@ -0,0 +1,83 @@ +/** + * 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.quartz.nipA0VoiceMessages + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyAuthorTag +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyEventTag +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyKindTag +import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.lastNotNullOfOrNull + +@Immutable +class VoiceReplyEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseVoiceEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun replyAuthor() = tags.firstNotNullOfOrNull(ReplyAuthorTag::parse) + + fun replyAuthors() = tags.filter(ReplyAuthorTag::match) + + fun replyAuthorKeys() = tags.mapNotNull(ReplyAuthorTag::parseKey) + + fun replyAuthorHints() = tags.mapNotNull(ReplyAuthorTag::parseAsHint) + + fun directReplies() = tags.filter { ReplyEventTag.match(it) } + + fun directKinds() = tags.filter(ReplyKindTag::match) + + fun markedReplyTos(): List = tags.mapNotNull(ReplyEventTag::parseKey) + + fun replyingTo(): HexKey? = tags.lastNotNullOfOrNull(ReplyEventTag::parseKey) + + companion object { + const val KIND = 1244 + const val ALT_DESCRIPTION = "Voice reply" + + fun build( + url: String, + mimeType: String?, + hash: String, + duration: Int, + waveform: List, + replyingTo: EventHintBundle, + ) = build(AudioMeta(url, mimeType, hash, duration, waveform), replyingTo) + + fun build( + voiceMessage: AudioMeta, + replyingTo: EventHintBundle, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = build(voiceMessage, KIND, ALT_DESCRIPTION, createdAt) { + replyEvent(replyingTo.event.id, replyingTo.relay, replyingTo.event.pubKey) + replyKind(replyingTo.event.kind) + replyAuthor(replyingTo.event.pubKey, replyingTo.authorHomeRelay) + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/DurationTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/DurationTag.kt new file mode 100644 index 0000000000..af56d81f2d --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/DurationTag.kt @@ -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.quartz.nipA0VoiceMessages.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class DurationTag { + companion object { + const val TAG_NAME = "duration" + + @JvmStatic + fun parse(tag: Array): Int? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toIntOrNull() + } + + @JvmStatic + fun assemble(seconds: Int) = arrayOf(TAG_NAME, seconds.toString()) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/HashSha256Tag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/HashSha256Tag.kt new file mode 100644 index 0000000000..9f42b8ad7f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/HashSha256Tag.kt @@ -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.quartz.nipA0VoiceMessages.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class HashSha256Tag { + companion object { + const val TAG_NAME = "sha256" + + @JvmStatic + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + @JvmStatic + fun assemble(hash: String) = arrayOf(TAG_NAME, hash) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/MimeTypeTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/MimeTypeTag.kt new file mode 100644 index 0000000000..2dc30e0397 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/MimeTypeTag.kt @@ -0,0 +1,46 @@ +/** + * 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.quartz.nipA0VoiceMessages.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class MimeTypeTag { + companion object { + const val TAG_NAME = "m" + + fun isIn( + tag: Array, + mimeTypes: Set, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in mimeTypes + + @JvmStatic + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + @JvmStatic + fun assemble(mimeType: String) = arrayOf(TAG_NAME, mimeType) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/ReplyAuthorTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/ReplyAuthorTag.kt new file mode 100644 index 0000000000..5853f5f7ef --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/ReplyAuthorTag.kt @@ -0,0 +1,85 @@ +/** + * 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.quartz.nipA0VoiceMessages.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +@Immutable +data class ReplyAuthorTag( + override val pubKey: HexKey, + override val relayHint: NormalizedRelayUrl? = null, +) : PubKeyReferenceTag { + fun toTagArray() = assemble(pubKey, relayHint) + + companion object { + const val TAG_NAME = "p" + + @JvmStatic + fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun parse(tag: Tag): ReplyAuthorTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return ReplyAuthorTag(tag[1], hint) + } + + @JvmStatic + fun parseKey(tag: Tag): HexKey? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + return tag[1] + } + + @JvmStatic + fun parseAsHint(tag: Array): PubKeyHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val hint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(hint != null) { return null } + + return PubKeyHint(tag[1], hint) + } + + @JvmStatic + fun assemble( + pubkey: HexKey, + relayHint: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint?.url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/ReplyEventTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/ReplyEventTag.kt new file mode 100644 index 0000000000..08c10fd9fa --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/ReplyEventTag.kt @@ -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.quartz.nipA0VoiceMessages.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.tags.events.EventReference +import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +@Immutable +class ReplyEventTag( + val ref: EventReference, +) { + constructor(eventId: String, relayHint: NormalizedRelayUrl?, pubkey: String?) : this(EventReference(eventId, pubkey, relayHint)) + + fun toTagArray() = assemble(ref) + + companion object { + const val TAG_NAME = "e" + + @JvmStatic + fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun isTagged( + tag: Array, + eventId: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == eventId + + @JvmStatic + fun isIn( + tag: Array, + eventIds: Set, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in eventIds + + @JvmStatic + fun parse(tag: Array): ReplyEventTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + return ReplyEventTag(tag[1], tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) }, tag.getOrNull(3)) + } + + @JvmStatic + fun parseKey(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + return tag[1] + } + + @JvmStatic + fun parseValidKey(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + ensure(Hex.isHex(tag[1])) { return null } + return tag[1] + } + + @JvmStatic + fun parseAsHint(tag: Array): EventIdHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(relayHint != null) { return null } + + return EventIdHint(tag[1], relayHint) + } + + @JvmStatic + fun assemble( + eventId: HexKey, + relay: NormalizedRelayUrl?, + pubkey: String?, + ) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, pubkey) + + @JvmStatic + fun assemble(ref: EventReference) = assemble(ref.eventId, ref.relayHint, ref.author) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/ReplyKindTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/ReplyKindTag.kt new file mode 100644 index 0000000000..7ecb255b7f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/ReplyKindTag.kt @@ -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.quartz.nipA0VoiceMessages.tags + +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId +import com.vitorpamplona.quartz.utils.ensure + +class ReplyKindTag { + companion object { + const val TAG_NAME = "k" + + @JvmStatic + fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun isKind( + tag: Tag, + kind: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == kind + + @JvmStatic + fun isTagged( + tag: Tag, + kind: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == kind + + @JvmStatic + fun isIn( + tag: Tag, + kinds: Set, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in kinds + + @JvmStatic + fun parse(tag: Tag): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + @JvmStatic + fun assemble(kind: String) = arrayOf(TAG_NAME, kind) + + @JvmStatic + fun assemble(kind: Int) = arrayOf(TAG_NAME, kind.toString()) + + @JvmStatic + fun assemble(id: ExternalId) = assemble(id.toKind()) + + @JvmStatic + fun assemble(kinds: List): List = kinds.map { assemble(it) } + + @JvmStatic + fun assemble(kinds: Set): List = kinds.map { assemble(it) } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/WaveformTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/WaveformTag.kt new file mode 100644 index 0000000000..d4366fcba5 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/WaveformTag.kt @@ -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.quartz.nipA0VoiceMessages.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class WaveformTag( + val wave: List, +) { + fun toTagArray() = assemble(wave) + + companion object { + const val TAG_NAME = "waveform" + + @JvmStatic + fun parse(tag: Array): WaveformTag? = parseWave(tag)?.let { WaveformTag(it) } + + @JvmStatic + fun parseWave(tag: Array): List? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + val wave = tag[1].split(" ").mapNotNull { it.toIntOrNull() } + if (wave.isEmpty()) return null + return wave + } + + fun parseWave(wave: String): List? { + val wave = wave.split(" ").mapNotNull { it.toIntOrNull() } + if (wave.isEmpty()) return null + return wave + } + + @JvmStatic + fun assembleWave(wave: List) = wave.joinToString(" ") + + @JvmStatic + fun assemble(wave: List) = arrayOf(TAG_NAME, assembleWave(wave)) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/blossom/BlossomAuthorizationEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt similarity index 81% rename from quartz/src/main/java/com/vitorpamplona/quartz/blossom/BlossomAuthorizationEvent.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt index cc8cd8f3e3..ca00e94991 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/blossom/BlossomAuthorizationEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt @@ -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,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.blossom +package com.vitorpamplona.quartz.nipB7Blossom import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event @@ -38,47 +38,42 @@ class BlossomAuthorizationEvent( companion object { const val KIND = 24242 - fun createGetAuth( + suspend fun createGetAuth( hash: HexKey, alt: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (BlossomAuthorizationEvent) -> Unit, - ) = createAuth("get", hash, null, alt, signer, createdAt, onReady) + ) = createAuth("get", hash, null, alt, signer, createdAt) - fun createListAuth( + suspend fun createListAuth( signer: NostrSigner, alt: String, createdAt: Long = TimeUtils.now(), - onReady: (BlossomAuthorizationEvent) -> Unit, - ) = createAuth("list", null, null, alt, signer, createdAt, onReady) + ) = createAuth("list", null, null, alt, signer, createdAt) - fun createDeleteAuth( + suspend fun createDeleteAuth( hash: HexKey, alt: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (BlossomAuthorizationEvent) -> Unit, - ) = createAuth("delete", hash, null, alt, signer, createdAt, onReady) + ) = createAuth("delete", hash, null, alt, signer, createdAt) - fun createUploadAuth( + suspend fun createUploadAuth( hash: HexKey, size: Long, alt: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (BlossomAuthorizationEvent) -> Unit, - ) = createAuth("upload", hash, size, alt, signer, createdAt, onReady) + ) = createAuth("upload", hash, size, alt, signer, createdAt) - private fun createAuth( + private suspend fun createAuth( type: String, hash: HexKey?, fileSize: Long?, alt: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (BlossomAuthorizationEvent) -> Unit, - ) { + ): BlossomAuthorizationEvent { val tags = listOfNotNull( arrayOf("t", type), @@ -87,7 +82,7 @@ class BlossomAuthorizationEvent( hash?.let { arrayOf("x", it) }, ) - signer.sign(createdAt, KIND, tags.toTypedArray(), alt, onReady) + return signer.sign(createdAt, KIND, tags.toTypedArray(), alt) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/blossom/BlossomServersEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipB7Blossom/BlossomServersEvent.kt similarity index 85% rename from quartz/src/main/java/com/vitorpamplona/quartz/blossom/BlossomServersEvent.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nipB7Blossom/BlossomServersEvent.kt index 3bb4f13a02..9e2c125702 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/blossom/BlossomServersEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipB7Blossom/BlossomServersEvent.kt @@ -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,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.blossom +package com.vitorpamplona.quartz.nipB7Blossom import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent @@ -64,13 +64,12 @@ class BlossomServersEvent( }.plusElement(AltTag.assemble(ALT)) .toTypedArray() - fun updateRelayList( + suspend fun updateRelayList( earlierVersion: BlossomServersEvent, relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (BlossomServersEvent) -> Unit, - ) { + ): BlossomServersEvent { val tags = earlierVersion.tags .filter { it[0] != "server" } @@ -80,25 +79,19 @@ class BlossomServersEvent( }, ).toTypedArray() - signer.sign(createdAt, KIND, tags, earlierVersion.content, onReady) + return signer.sign(createdAt, KIND, tags, earlierVersion.content) } - fun createFromScratch( + suspend fun createFromScratch( relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (BlossomServersEvent) -> Unit, - ) { - create(relays, signer, createdAt, onReady) - } + ) = create(relays, signer, createdAt) - fun create( + suspend fun create( servers: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (BlossomServersEvent) -> Unit, - ) { - signer.sign(createdAt, KIND, createTagArray(servers), "", onReady) - } + ) = signer.sign(createdAt, KIND, createTagArray(servers), "") } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/ArrayUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/ArrayUtils.kt index e33916bfd0..db36dd7cef 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/ArrayUtils.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/ArrayUtils.kt @@ -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 @@ -40,6 +40,8 @@ fun Array.startsWith(startsWith: Array): Boolean { return true } +fun Array.startsWithAny(startsWithList: List>): Boolean = startsWithList.any { startsWith(it) } + public inline fun Array.lastNotNullOfOrNull(transform: (T) -> R?): R? { for (index in this.indices.reversed()) { val result = transform(this[index]) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/Ensure.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/Ensure.kt index c1ec0bce2e..f6422bf5e6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/Ensure.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/Ensure.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/GZip.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/GZip.kt new file mode 100644 index 0000000000..8bba32709d --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/GZip.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +import java.io.ByteArrayOutputStream +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream + +class GZip { + companion object { + fun compress(content: String): ByteArray { + val bos = ByteArrayOutputStream() + GZIPOutputStream(bos).bufferedWriter(Charsets.UTF_8).use { it.write(content) } + return bos.toByteArray() + } + + fun decompress(content: ByteArray): String = GZIPInputStream(content.inputStream()).bufferedReader(Charsets.UTF_8).use { it.readText() } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/Hex.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/Hex.kt index e1ae0e5002..270d66a5e0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/Hex.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/Hex.kt @@ -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 @@ -43,6 +43,7 @@ object Hex { try { for (c in hex.indices) { + if (c < 0 || c > 255) return false if (hexToByte[hex[c].code] < 0) return false } } catch (e: IllegalArgumentException) { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/Hmac512.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/Hmac512.kt index c19f7cc4b6..c40a8767a4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/Hmac512.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/Hmac512.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/IterableExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/IterableExt.kt index bc0073143c..b3f624e6d4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/IterableExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/IterableExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/LargeCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/LargeCache.kt new file mode 100644 index 0000000000..bd61c05d19 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/LargeCache.kt @@ -0,0 +1,564 @@ +/** + * 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.quartz.utils + +import java.util.concurrent.ConcurrentSkipListMap +import java.util.function.BiConsumer + +class LargeCache { + private val cache = ConcurrentSkipListMap() + + fun keys() = cache.keys + + fun values() = cache.values + + fun get(key: K) = cache.get(key) + + fun remove(key: K) = cache.remove(key) + + fun size() = cache.size + + fun isEmpty() = cache.isEmpty() + + fun clear() = cache.clear() + + fun containsKey(key: K) = cache.containsKey(key) + + fun put( + key: K, + value: V, + ) { + cache.put(key, value) + } + + fun getOrCreate( + key: K, + builder: (key: K) -> V, + ): V { + val value = cache.get(key) + + return if (value != null) { + value + } else { + val newObject = builder(key) + cache.putIfAbsent(key, newObject) ?: newObject + } + } + + fun createIfAbsent( + key: K, + builder: (key: K) -> V, + ): Boolean { + val value = cache.get(key) + return if (value != null) { + false + } else { + val newObject = builder(key) + cache.putIfAbsent(key, newObject) == null + } + } + + fun forEach(consumer: BiConsumer) { + innerForEach(consumer) + } + + fun filter(consumer: BiFilter): List { + val runner = BiFilterCollector(consumer) + innerForEach(runner) + return runner.results + } + + fun filterIntoSet(consumer: BiFilter): Set { + val runner = BiFilterUniqueCollector(consumer) + innerForEach(runner) + return runner.results + } + + fun map(consumer: BiNotNullMapper): List { + val runner = BiNotNullMapCollector(consumer) + innerForEach(runner) + return runner.results + } + + fun mapNotNull(consumer: BiMapper): List { + val runner = BiMapCollector(consumer) + innerForEach(runner) + return runner.results + } + + fun mapNotNullIntoSet(consumer: BiMapper): Set { + val runner = BiMapUniqueCollector(consumer) + innerForEach(runner) + return runner.results + } + + fun mapFlatten(consumer: BiMapper?>): List { + val runner = BiMapFlattenCollector(consumer) + innerForEach(runner) + return runner.results + } + + fun mapFlattenIntoSet(consumer: BiMapper?>): Set { + val runner = BiMapFlattenUniqueCollector(consumer) + innerForEach(runner) + return runner.results + } + + fun maxOrNullOf( + filter: BiFilter, + comparator: Comparator, + ): V? { + val runner = BiMaxOfCollector(filter, comparator) + innerForEach(runner) + return runner.maxV + } + + fun sumOf(consumer: BiSumOf): Int { + val runner = BiSumOfCollector(consumer) + innerForEach(runner) + return runner.sum + } + + fun sumOfLong(consumer: BiSumOfLong): Long { + val runner = BiSumOfLongCollector(consumer) + innerForEach(runner) + return runner.sum + } + + fun groupBy(consumer: BiNotNullMapper): Map> { + val runner = BiGroupByCollector(consumer) + innerForEach(runner) + return runner.results + } + + fun countByGroup(consumer: BiNotNullMapper): Map { + val runner = BiCountByGroupCollector(consumer) + innerForEach(runner) + return runner.results + } + + fun sumByGroup( + groupMap: BiNotNullMapper, + sumOf: BiNotNullMapper, + ): Map { + val runner = BiSumByGroupCollector(groupMap, sumOf) + innerForEach(runner) + return runner.results + } + + fun count(consumer: BiFilter): Int { + val runner = BiCountIfCollector(consumer) + innerForEach(runner) + return runner.count + } + + fun associate(transform: (K, V) -> Pair): Map { + val runner = BiAssociateCollector(size(), transform) + innerForEach(runner) + return runner.results + } + + fun associateNotNull(transform: (K, V) -> Pair?): Map { + val runner = BiAssociateNotNullCollector(size(), transform) + innerForEach(runner) + return runner.results + } + + fun associateWith(transform: (K, V) -> U?): Map { + val runner = BiAssociateWithCollector(size(), transform) + innerForEach(runner) + return runner.results + } + + fun associateNotNullWith(transform: (K, V) -> U): Map { + val runner = BiAssociateNotNullWithCollector(size(), transform) + innerForEach(runner) + return runner.results + } + + private fun innerForEach(runner: BiConsumer) { + cache.forEach(runner) + } + + fun joinToString( + separator: CharSequence = ", ", + prefix: CharSequence = "", + postfix: CharSequence = "", + limit: Int = -1, + truncated: CharSequence = "...", + transform: ((K, V) -> CharSequence)? = null, + ): String { + val buffer = StringBuilder() + buffer.append(prefix) + var count = 0 + forEach { key, value -> + val str = if (transform != null) transform(key, value) else "" + if (str.isNotEmpty()) { + if (++count > 1) buffer.append(separator) + if (limit < 0 || count <= limit) { + when { + transform != null -> buffer.append(str) + else -> buffer.append("$key $value") + } + } else { + return@forEach + } + } + } + if (limit >= 0 && count > limit) buffer.append(truncated) + buffer.append(postfix) + return buffer.toString() + } + + fun interface BiFilter { + fun filter( + k: K, + v: V, + ): Boolean + } + + class BiFilterCollector( + val filter: BiFilter, + ) : BiConsumer { + var results: ArrayList = ArrayList() + + override fun accept( + k: K, + v: V, + ) { + if (filter.filter(k, v)) { + results.add(v) + } + } + } + + class BiFilterUniqueCollector( + val filter: BiFilter, + ) : BiConsumer { + var results: HashSet = HashSet() + + override fun accept( + k: K, + v: V, + ) { + if (filter.filter(k, v)) { + results.add(v) + } + } + } + + fun interface BiMapper { + fun map( + k: K, + v: V, + ): R? + } + + fun interface BiMapperNotNull { + fun map( + k: K, + v: V, + ): R + } + + class BiMapCollector( + val mapper: BiMapper, + ) : BiConsumer { + var results: ArrayList = ArrayList() + + override fun accept( + k: K, + v: V, + ) { + val result = mapper.map(k, v) + if (result != null) { + results.add(result) + } + } + } + + class BiAssociateCollector( + val size: Int, + val mapper: BiMapperNotNull>, + ) : BiConsumer { + var results: LinkedHashMap = LinkedHashMap(size) + + override fun accept( + k: K, + v: V, + ) { + val pair = mapper.map(k, v) + results.put(pair.first, pair.second) + } + } + + class BiAssociateNotNullCollector( + val size: Int, + val mapper: BiMapper?>, + ) : BiConsumer { + var results: LinkedHashMap = LinkedHashMap(size) + + override fun accept( + k: K, + v: V, + ) { + val pair = mapper.map(k, v) + if (pair != null) { + results.put(pair.first, pair.second) + } + } + } + + class BiAssociateWithCollector( + val size: Int, + val mapper: BiMapper, + ) : BiConsumer { + var results: LinkedHashMap = LinkedHashMap(size) + + override fun accept( + k: K, + v: V, + ) { + results.put(k, mapper.map(k, v)) + } + } + + class BiAssociateNotNullWithCollector( + val size: Int, + val mapper: BiMapper, + ) : BiConsumer { + var results: LinkedHashMap = LinkedHashMap(size) + + override fun accept( + k: K, + v: V, + ) { + val newValue = mapper.map(k, v) + if (newValue != null) { + results.put(k, newValue) + } + } + } + + class BiMapUniqueCollector( + val mapper: BiMapper, + ) : BiConsumer { + var results: HashSet = HashSet() + + override fun accept( + k: K, + v: V, + ) { + val result = mapper.map(k, v) + if (result != null) { + results.add(result) + } + } + } + + class BiMapFlattenCollector( + val mapper: BiMapper?>, + ) : BiConsumer { + var results: ArrayList = ArrayList() + + override fun accept( + k: K, + v: V, + ) { + val result = mapper.map(k, v) + if (result != null) { + results.addAll(result) + } + } + } + + class BiMapFlattenUniqueCollector( + val mapper: BiMapper?>, + ) : BiConsumer { + var results: HashSet = HashSet() + + override fun accept( + k: K, + v: V, + ) { + val result = mapper.map(k, v) + if (result != null) { + results.addAll(result) + } + } + } + + fun interface BiNotNullMapper { + fun map( + k: K, + v: V, + ): R + } + + class BiNotNullMapCollector( + val mapper: BiNotNullMapper, + ) : BiConsumer { + var results: ArrayList = ArrayList() + + override fun accept( + k: K, + v: V, + ) { + results.add(mapper.map(k, v)) + } + } + + fun interface BiSumOf { + fun map( + k: K, + v: V, + ): Int + } + + class BiMaxOfCollector( + val filter: BiFilter, + val comparator: Comparator, + ) : BiConsumer { + var maxK: K? = null + var maxV: V? = null + + override fun accept( + k: K, + v: V, + ) { + if (filter.filter(k, v)) { + if (maxK == null || comparator.compare(v, maxV) > 0) { + maxK = k + maxV = v + } + } + } + } + + class BiSumOfCollector( + val mapper: BiSumOf, + ) : BiConsumer { + var sum = 0 + + override fun accept( + k: K, + v: V, + ) { + sum += mapper.map(k, v) + } + } + + fun interface BiSumOfLong { + fun map( + k: K, + v: V, + ): Long + } + + class BiSumOfLongCollector( + val mapper: BiSumOfLong, + ) : BiConsumer { + var sum = 0L + + override fun accept( + k: K, + v: V, + ) { + sum += mapper.map(k, v) + } + } + + class BiGroupByCollector( + val mapper: BiNotNullMapper, + ) : BiConsumer { + var results = HashMap>() + + override fun accept( + k: K, + v: V, + ) { + val group = mapper.map(k, v) + + val list = results[group] + if (list == null) { + val answer = ArrayList() + answer.add(v) + results[group] = answer + } else { + list.add(v) + } + } + } + + class BiCountByGroupCollector( + val mapper: BiNotNullMapper, + ) : BiConsumer { + var results = HashMap() + + override fun accept( + k: K, + v: V, + ) { + val group = mapper.map(k, v) + + val count = results[group] + if (count == null) { + results[group] = 1 + } else { + results[group] = count + 1 + } + } + } + + class BiSumByGroupCollector( + val mapper: BiNotNullMapper, + val sumOf: BiNotNullMapper, + ) : BiConsumer { + var results = HashMap() + + override fun accept( + k: K, + v: V, + ) { + val group = mapper.map(k, v) + + val sum = results[group] + if (sum == null) { + results[group] = sumOf.map(k, v) + } else { + results[group] = sum + sumOf.map(k, v) + } + } + } + + class BiCountIfCollector( + val filter: BiFilter, + ) : BiConsumer { + var count = 0 + + override fun accept( + k: K, + v: V, + ) { + if (filter.filter(k, v)) count++ + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/LargeSoftCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/LargeSoftCache.kt new file mode 100644 index 0000000000..50f539bd69 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/LargeSoftCache.kt @@ -0,0 +1,627 @@ +/** + * 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.quartz.utils + +import java.lang.ref.WeakReference +import java.util.concurrent.ConcurrentSkipListMap +import java.util.function.BiConsumer + +class LargeSoftCache { + private val cache = ConcurrentSkipListMap>() + + fun keys() = cache.keys + + fun get(key: K): V? { + val softRef = cache.get(key) + val value = softRef?.get() + + return if (value != null) { + value + } else { + cache.remove(key) + null + } + } + + fun remove(key: K) = cache.remove(key) + + fun size() = cache.size + + fun isEmpty() = cache.isEmpty() + + fun clear() = cache.clear() + + fun containsKey(key: K) = cache.containsKey(key) + + /** + * Puts an object into the cache with a specified key. + * The object is stored as a SoftReference. + * + * @param key The key to associate with the object. + * @param value The object to cache. + */ + fun put( + key: K, + value: V, + ) { + cache.put(key, WeakReference(value)) + } + + /** + * Retrieves an object from the cache using its key. + * Returns the object if it's still available (not garbage collected), + * otherwise returns null. If the object has been garbage collected, + * its entry is also removed from the cache. + * + * @param key The key of the object to retrieve. + * @return The cached object, or null if it's no longer available. + */ + fun getOrCreate( + key: K, + builder: (key: K) -> V, + ): V { + val softRef = cache.get(key) + val value = softRef?.get() + + return if (value != null) { + value + } else { + val newObject = builder(key) + cache.putIfAbsent(key, WeakReference(newObject))?.get() ?: newObject + } + } + + fun createIfAbsent( + key: K, + builder: (key: K) -> V, + ): Boolean { + val softRef = cache.get(key) + val value = softRef?.get() + return if (value != null) { + false + } else { + val newObject = builder(key) + cache.putIfAbsent(key, WeakReference(newObject)) == null + } + } + + /** + * Proactively cleans up the cache by removing entries whose weakly referenced + * objects have been garbage collected. While `get` handles cleanup on access, + * this method can be called periodically or when memory pressure is high. + */ + fun cleanUp() { + val keysToRemove = mutableListOf() + forEach { key, softRef -> + if (softRef == null) { + keysToRemove.add(key) + } + } + keysToRemove.forEach { key -> + cache.remove(key) + println("Cleaned up entry for key: $key (object was garbage collected)") + } + println("Cache cleanup completed. Remaining size: ${cache.size}") + } + + fun forEach(consumer: BiConsumer) { + innerForEach(consumer) + } + + fun filter(consumer: BiFilter): List { + val runner = BiFilterCollector(consumer) + innerForEach(runner) + return runner.results + } + + fun filterIntoSet(consumer: BiFilter): Set { + val runner = BiFilterUniqueCollector(consumer) + innerForEach(runner) + return runner.results + } + + fun map(consumer: BiNotNullMapper): List { + val runner = BiNotNullMapCollector(consumer) + innerForEach(runner) + return runner.results + } + + fun mapNotNull(consumer: BiMapper): List { + val runner = BiMapCollector(consumer) + innerForEach(runner) + return runner.results + } + + fun mapNotNullIntoSet(consumer: BiMapper): Set { + val runner = BiMapUniqueCollector(consumer) + innerForEach(runner) + return runner.results + } + + fun mapFlatten(consumer: BiMapper?>): List { + val runner = BiMapFlattenCollector(consumer) + innerForEach(runner) + return runner.results + } + + fun mapFlattenIntoSet(consumer: BiMapper?>): Set { + val runner = BiMapFlattenUniqueCollector(consumer) + innerForEach(runner) + return runner.results + } + + fun maxOrNullOf( + filter: BiFilter, + comparator: Comparator, + ): V? { + val runner = BiMaxOfCollector(filter, comparator) + innerForEach(runner) + return runner.maxV + } + + fun sumOf(consumer: BiSumOf): Int { + val runner = BiSumOfCollector(consumer) + innerForEach(runner) + return runner.sum + } + + fun sumOfLong(consumer: BiSumOfLong): Long { + val runner = BiSumOfLongCollector(consumer) + innerForEach(runner) + return runner.sum + } + + fun groupBy(consumer: BiNotNullMapper): Map> { + val runner = BiGroupByCollector(consumer) + innerForEach(runner) + return runner.results + } + + fun countByGroup(consumer: BiNotNullMapper): Map { + val runner = BiCountByGroupCollector(consumer) + innerForEach(runner) + return runner.results + } + + fun sumByGroup( + groupMap: BiNotNullMapper, + sumOf: BiNotNullMapper, + ): Map { + val runner = BiSumByGroupCollector(groupMap, sumOf) + innerForEach(runner) + return runner.results + } + + fun count(consumer: BiFilter): Int { + val runner = BiCountIfCollector(consumer) + innerForEach(runner) + return runner.count + } + + fun associate(transform: (K, V) -> Pair): Map { + val runner = BiAssociateCollector(size(), transform) + innerForEach(runner) + return runner.results + } + + fun associateNotNull(transform: (K, V) -> Pair?): Map { + val runner = BiAssociateNotNullCollector(size(), transform) + innerForEach(runner) + return runner.results + } + + fun associateWith(transform: (K, V) -> U?): Map { + val runner = BiAssociateWithCollector(size(), transform) + innerForEach(runner) + return runner.results + } + + fun associateNotNullWith(transform: (K, V) -> U): Map { + val runner = BiAssociateNotNullWithCollector(size(), transform) + innerForEach(runner) + return runner.results + } + + private fun innerForEach(runner: BiConsumer) { + cache.forEach(BiConsumerWrapper(this, runner)) + } + + fun joinToString( + separator: CharSequence = ", ", + prefix: CharSequence = "", + postfix: CharSequence = "", + limit: Int = -1, + truncated: CharSequence = "...", + transform: ((K, V) -> CharSequence)? = null, + ): String { + val buffer = StringBuilder() + buffer.append(prefix) + var count = 0 + forEach { key, value -> + val str = if (transform != null) transform(key, value) else "" + if (str.isNotEmpty()) { + if (++count > 1) buffer.append(separator) + if (limit < 0 || count <= limit) { + when { + transform != null -> buffer.append(str) + else -> buffer.append("$key $value") + } + } else { + return@forEach + } + } + } + if (limit >= 0 && count > limit) buffer.append(truncated) + buffer.append(postfix) + return buffer.toString() + } + + class BiConsumerWrapper( + val cache: LargeSoftCache, + val inner: BiConsumer, + ) : BiConsumer> { + override fun accept( + k: K, + ref: WeakReference, + ) { + val value = ref.get() + if (value == null) { + cache.remove(k) + } else { + inner.accept(k, value) + } + } + } + + fun interface BiFilter { + fun filter( + k: K, + v: V, + ): Boolean + } + + class BiFilterCollector( + val filter: BiFilter, + ) : BiConsumer { + var results: ArrayList = ArrayList() + + override fun accept( + k: K, + v: V, + ) { + if (filter.filter(k, v)) { + results.add(v) + } + } + } + + class BiFilterUniqueCollector( + val filter: BiFilter, + ) : BiConsumer { + var results: HashSet = HashSet() + + override fun accept( + k: K, + v: V, + ) { + if (filter.filter(k, v)) { + results.add(v) + } + } + } + + fun interface BiMapper { + fun map( + k: K, + v: V, + ): R? + } + + fun interface BiMapperNotNull { + fun map( + k: K, + v: V, + ): R + } + + class BiMapCollector( + val mapper: BiMapper, + ) : BiConsumer { + var results: ArrayList = ArrayList() + + override fun accept( + k: K, + v: V, + ) { + val result = mapper.map(k, v) + if (result != null) { + results.add(result) + } + } + } + + class BiAssociateCollector( + val size: Int, + val mapper: BiMapperNotNull>, + ) : BiConsumer { + var results: LinkedHashMap = LinkedHashMap(size) + + override fun accept( + k: K, + v: V, + ) { + val pair = mapper.map(k, v) + results.put(pair.first, pair.second) + } + } + + class BiAssociateNotNullCollector( + val size: Int, + val mapper: BiMapper?>, + ) : BiConsumer { + var results: LinkedHashMap = LinkedHashMap(size) + + override fun accept( + k: K, + v: V, + ) { + val pair = mapper.map(k, v) + if (pair != null) { + results.put(pair.first, pair.second) + } + } + } + + class BiAssociateWithCollector( + val size: Int, + val mapper: BiMapper, + ) : BiConsumer { + var results: LinkedHashMap = LinkedHashMap(size) + + override fun accept( + k: K, + v: V, + ) { + results.put(k, mapper.map(k, v)) + } + } + + class BiAssociateNotNullWithCollector( + val size: Int, + val mapper: BiMapper, + ) : BiConsumer { + var results: LinkedHashMap = LinkedHashMap(size) + + override fun accept( + k: K, + v: V, + ) { + val newValue = mapper.map(k, v) + if (newValue != null) { + results.put(k, newValue) + } + } + } + + class BiMapUniqueCollector( + val mapper: BiMapper, + ) : BiConsumer { + var results: HashSet = HashSet() + + override fun accept( + k: K, + v: V, + ) { + val result = mapper.map(k, v) + if (result != null) { + results.add(result) + } + } + } + + class BiMapFlattenCollector( + val mapper: BiMapper?>, + ) : BiConsumer { + var results: ArrayList = ArrayList() + + override fun accept( + k: K, + v: V, + ) { + val result = mapper.map(k, v) + if (result != null) { + results.addAll(result) + } + } + } + + class BiMapFlattenUniqueCollector( + val mapper: BiMapper?>, + ) : BiConsumer { + var results: HashSet = HashSet() + + override fun accept( + k: K, + v: V, + ) { + val result = mapper.map(k, v) + if (result != null) { + results.addAll(result) + } + } + } + + fun interface BiNotNullMapper { + fun map( + k: K, + v: V, + ): R + } + + class BiNotNullMapCollector( + val mapper: BiNotNullMapper, + ) : BiConsumer { + var results: ArrayList = ArrayList() + + override fun accept( + k: K, + v: V, + ) { + results.add(mapper.map(k, v)) + } + } + + fun interface BiSumOf { + fun map( + k: K, + v: V, + ): Int + } + + class BiMaxOfCollector( + val filter: BiFilter, + val comparator: Comparator, + ) : BiConsumer { + var maxK: K? = null + var maxV: V? = null + + override fun accept( + k: K, + v: V, + ) { + if (filter.filter(k, v)) { + if (maxK == null || comparator.compare(v, maxV) > 0) { + maxK = k + maxV = v + } + } + } + } + + class BiSumOfCollector( + val mapper: BiSumOf, + ) : BiConsumer { + var sum = 0 + + override fun accept( + k: K, + v: V, + ) { + sum += mapper.map(k, v) + } + } + + fun interface BiSumOfLong { + fun map( + k: K, + v: V, + ): Long + } + + class BiSumOfLongCollector( + val mapper: BiSumOfLong, + ) : BiConsumer { + var sum = 0L + + override fun accept( + k: K, + v: V, + ) { + sum += mapper.map(k, v) + } + } + + class BiGroupByCollector( + val mapper: BiNotNullMapper, + ) : BiConsumer { + var results = HashMap>() + + override fun accept( + k: K, + v: V, + ) { + val group = mapper.map(k, v) + + val list = results[group] + if (list == null) { + val answer = ArrayList() + answer.add(v) + results[group] = answer + } else { + list.add(v) + } + } + } + + class BiCountByGroupCollector( + val mapper: BiNotNullMapper, + ) : BiConsumer { + var results = HashMap() + + override fun accept( + k: K, + v: V, + ) { + val group = mapper.map(k, v) + + val count = results[group] + if (count == null) { + results[group] = 1 + } else { + results[group] = count + 1 + } + } + } + + class BiSumByGroupCollector( + val mapper: BiNotNullMapper, + val sumOf: BiNotNullMapper, + ) : BiConsumer { + var results = HashMap() + + override fun accept( + k: K, + v: V, + ) { + val group = mapper.map(k, v) + + val sum = results[group] + if (sum == null) { + results[group] = sumOf.map(k, v) + } else { + results[group] = sum + sumOf.map(k, v) + } + } + } + + class BiCountIfCollector( + val filter: BiFilter, + ) : BiConsumer { + var count = 0 + + override fun accept( + k: K, + v: V, + ) { + if (filter.filter(k, v)) count++ + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/LibSodiumInstance.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/LibSodiumInstance.kt index cdffe0a241..6c327f4238 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/LibSodiumInstance.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/LibSodiumInstance.kt @@ -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 @@ -82,7 +82,7 @@ object LibSodiumInstance { messageBytes: ByteArray, nonce: ByteArray, key: ByteArray, - ): ByteArray? { + ): ByteArray { val cipher = ByteArray(messageBytes.size) val k2 = ByteArray(32) @@ -100,6 +100,6 @@ object LibSodiumInstance { k2, ) - return if (resultCode == 0) cipher else null + return if (resultCode == 0) cipher else throw IllegalStateException("Could not decrypt message") } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/AmethystNostrDataSource.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/MapOfSetBuilder.kt similarity index 53% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/AmethystNostrDataSource.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/utils/MapOfSetBuilder.kt index 49185405b5..98d7db8e91 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/AmethystNostrDataSource.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/MapOfSetBuilder.kt @@ -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,35 +18,53 @@ * 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 +package com.vitorpamplona.quartz.utils -import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.ammolite.relays.NostrDataSource -import com.vitorpamplona.ammolite.relays.Relay -import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent -import com.vitorpamplona.quartz.nip01Core.core.Event +class MapOfSetBuilder { + val data = mutableMapOf>() -abstract class AmethystNostrDataSource( - debugName: String, -) : NostrDataSource(Amethyst.instance.client, debugName) { - override fun consume( - event: Event, - relay: Relay, + fun add( + key: K, + value: V, ) { - LocalCache.verifyAndConsume(event, relay) - } - - override fun markAsSeenOnRelay( - eventId: String, - relay: Relay, - ) { - val note = LocalCache.getNoteIfExists(eventId) - val noteEvent = note?.event - if (noteEvent is AddressableEvent) { - LocalCache.getAddressableNoteIfExists(noteEvent.aTag().toTag())?.addRelay(relay) + val set = data[key] + if (set == null) { + data.put(key, mutableSetOf(value)) } else { - note?.addRelay(relay) + set.add(value) } } + + fun add( + key: K, + value: Set, + ) { + val set = data[key] + if (set == null) { + data.put(key, value.toMutableSet()) + } else { + set.addAll(value) + } + } + + fun add(map: Map>) { + map.forEach { + add(it.key, it.value) + } + } + + fun build(): Map> = data } + +fun mapOfSet(init: MapOfSetBuilder.() -> Unit): Map> { + val data = MapOfSetBuilder() + data.init() + return data.build() +} + +fun merge(maps: List>>): Map> = + mapOfSet { + maps.forEach { map -> + add(map) + } + } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/ParallelUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/ParallelUtils.kt new file mode 100644 index 0000000000..b47f4250b4 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/ParallelUtils.kt @@ -0,0 +1,247 @@ +/** + * 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.quartz.utils + +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.selects.select +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.coroutines.cancellation.CancellationException +import kotlin.coroutines.resume + +/** + * Launches an async coroutine for each item, runs the + * function and waits for everybody to finsih + */ +suspend fun launchAndWaitAll( + items: List, + asyncFunc: suspend (T) -> Unit, +) { + coroutineScope { + val jobs = + items.map { next -> + async { + asyncFunc(next) + } + } + + // runs in parallel to avoid overcrowding Amber. + withTimeoutOrNull(15000) { + jobs.joinAll() + } + + async { + jobs.forEach { + it.cancel("Timeout") + } + } + } +} + +/** + * Runs the function and waits for 10 seconds for any result. + */ +suspend inline fun tryAndWait( + timeoutMillis: Long = 10000, + crossinline asyncFunc: (CancellableContinuation) -> 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 collectSuccessfulOperationsReturning( + items: List, + runRequestFor: (T, (K) -> Unit) -> Unit, +): List { + val output: MutableList = mutableListOf() + + if (items.isEmpty()) { + return output + } + + launchAndWaitAll(items) { + val result = + tryAndWait { continuation -> + runRequestFor(it) { result: K -> continuation.resume(result) } + } + + if (result != null) { + output.add(result) + } + } + + return output +} + +/** + * Executes multiple suspending functions concurrently using `async` and attempts to wait for all of them + * to complete within a default 15-second timeout. + * + * If the timeout is reached, it returns the results of all tasks that successfully completed by then + * and cancels any tasks that are still running. Tasks that completed with an exception are not + * included in the returned list. + * + * @param tasks A list of suspending functions, each returning a value of type [T]. + * @return A list containing the results of tasks that successfully completed within the timeout. + */ +@OptIn(ExperimentalCoroutinesApi::class) +suspend fun mapNotNullAsync( + items: List, + timeoutMillis: Long = 30000, + runRequestFor: suspend (T) -> K?, +): List { + if (items.isEmpty()) { + return emptyList() + } + + return coroutineScope { + // Launch all tasks asynchronously and get their Deferred handles. + val jobs = + items.map { item -> + async { + runRequestFor(item) + } + } + + // Use withTimeout to impose a 15-second limit on waiting for all deferreds. + // If all tasks complete within 15 seconds, awaitAll() will return their results, + // and this block will return those results. + withTimeoutOrNull(timeoutMillis) { + jobs.joinAll() + } + + async { + jobs.forEach { + it.cancel("Timeout") + } + } + + jobs.mapNotNull { + if (it.isCompleted) { + it.getCompleted() + } else { + null + } + } + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +suspend fun mapNotNullAsyncSimple( + items: List, + runRequestFor: suspend (T) -> K?, +): List { + if (items.isEmpty()) { + return emptyList() + } + + return coroutineScope { + // Launch all tasks asynchronously and get their Deferred handles. + val jobs = + items.map { item -> + async { + runRequestFor(item) + } + } + + jobs.joinAll() + + jobs.mapNotNull { + if (it.isCompleted) { + it.getCompleted() + } else { + null + } + } + } +} + +/** + * Executes a mapping function asynchronously on each input in the list. + * Returns true as soon as the first mapping returns true, cancelling all other ongoing operations. + * + * @param inputs A list of input objects to process. + * @param mappingFunction A suspend function that takes an input object and returns a Boolean. + * @return True if any mapping function returns true, false otherwise. + */ +suspend fun anyAsync( + inputs: List, + timeoutMillis: Long = 30000, + mappingFunction: suspend (T) -> Boolean, +): Boolean = + coroutineScope { + // Create a list to hold all our deferred results + val deferredResults = + inputs.map { input -> + async { + // Each async block will execute the mapping function. + // If this coroutine gets cancelled, CancellationException will be thrown, + // and we'll catch it to ensure it doesn't propagate further. + try { + mappingFunction(input) + } catch (e: CancellationException) { + // When cancelled, we treat it as if it didn't return true + false + } + } + } + + // Use select to wait for the first deferred to complete with 'true' + val foundTrue = + withTimeoutOrNull(timeoutMillis) { + select { + deferredResults.forEach { deferred -> + // For each deferred, if it completes and its result is 'true', + // this branch of the select expression will be chosen. + deferred.onAwait { result -> + if (result) { + true // Return true from the select expression + } else { + // If a deferred completes with false, we don't want to + // immediately end the select, so we return false, which + // lets select continue waiting for other branches. + false + } + } + } + } + } + + // Once select returns (either with true or after all deferreds complete/are cancelled), + // cancel any remaining ongoing operations. + // If foundTrue is true, all other deferreds are implicitly cancelled by the select winning. + // If foundTrue is false, it means all completed with false or were cancelled. + deferredResults.forEach { it.cancel() } // Ensure all are cancelled. + + return@coroutineScope foundTrue == true + } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/RandomInstance.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/RandomInstance.kt index 01b03b8217..5063f34277 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/RandomInstance.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/RandomInstance.kt @@ -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 @@ -28,4 +28,10 @@ object RandomInstance { fun int(bound: Int = Int.MAX_VALUE) = randomizer.nextInt(bound) fun bytes(size: Int) = ByteArray(size).also { randomizer.nextBytes(it) } + + val charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9') + + fun randomChar() = charPool[randomizer.nextInt(charPool.size)] + + fun randomChars(size: Int = 16) = String(CharArray(size) { randomChar() }) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/Secp256k1Instance.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/Secp256k1Instance.kt index 814514cb4c..770a8f13c5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/Secp256k1Instance.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/Secp256k1Instance.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/StringUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/StringUtils.kt index 8cfb1ce11d..557ca3fdb0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/StringUtils.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/StringUtils.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/TimeUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/TimeUtils.kt index 8193397795..45d83bfc9e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/TimeUtils.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/TimeUtils.kt @@ -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,6 +21,7 @@ package com.vitorpamplona.quartz.utils object TimeUtils { + const val TEN_SECONDS = 10 const val ONE_MINUTE = 60 const val FIVE_MINUTES = 5 * ONE_MINUTE const val FIFTEEN_MINUTES = 15 * ONE_MINUTE @@ -33,6 +34,8 @@ object TimeUtils { fun now() = System.currentTimeMillis() / 1000 + fun tenSecondsFromNow() = now() + TEN_SECONDS + fun oneMinuteFromNow() = now() + ONE_MINUTE fun oneMinuteAgo() = now() - ONE_MINUTE diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/UriReferenceExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/UriReferenceExt.kt index 022055ca29..4de756818d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/UriReferenceExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/UriReferenceExt.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/sha256/Sha256.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/sha256/Sha256.kt index 9bd9a065f7..7e30261909 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/sha256/Sha256.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/sha256/Sha256.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/sha256/Sha256Hasher.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/sha256/Sha256Hasher.kt index 533151a931..bd47097806 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/sha256/Sha256Hasher.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/sha256/Sha256Hasher.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/sha256/Sha256Pool.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/sha256/Sha256Pool.kt index 5b31af6fdc..cb0561e8db 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/sha256/Sha256Pool.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/sha256/Sha256Pool.kt @@ -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 diff --git a/quartz/src/test/java/android/util/Log.java b/quartz/src/test/java/android/util/Log.java new file mode 100644 index 0000000000..af85d0b47e --- /dev/null +++ b/quartz/src/test/java/android/util/Log.java @@ -0,0 +1,35 @@ +package android.util; + +public class Log { + public static int d(String tag, String msg) { + System.out.println("DEBUG: " + tag + ": " + msg); + return 0; + } + + public static int i(String tag, String msg) { + System.out.println("INFO: " + tag + ": " + msg); + return 0; + } + + public static int w(String tag, String msg) { + System.out.println("WARN: " + tag + ": " + msg); + return 0; + } + + public static int w(String tag, String msg, Throwable e) { + System.out.println("WARN: " + tag + ": " + msg); + e.printStackTrace(); + return 0; + } + + public static int e(String tag, String msg) { + System.out.println("ERROR: " + tag + ": " + msg); + return 0; + } + + public static int e(String tag, String msg, Throwable e) { + System.out.println("ERROR: " + tag + ": " + msg); + e.printStackTrace(); + return 0; + } +} \ No newline at end of file diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/lightning/Lud06Test.kt b/quartz/src/test/java/com/vitorpamplona/quartz/lightning/Lud06Test.kt index 4c925921bf..2afb0bb415 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/lightning/Lud06Test.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/lightning/Lud06Test.kt @@ -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 diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/hints/BloomFilterMurMur3Test.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/hints/BloomFilterMurMur3Test.kt index d4364dfc43..20887c96ba 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/hints/BloomFilterMurMur3Test.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/hints/BloomFilterMurMur3Test.kt @@ -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 diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/hints/HintIndexerTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/hints/HintIndexerTest.kt index 49b463648a..f58a4cf7da 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/hints/HintIndexerTest.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/hints/HintIndexerTest.kt @@ -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,7 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.hints import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip01Core.hints.HintIndexerTest.Companion.indexer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.usedMemoryMb @@ -64,6 +64,7 @@ class HintIndexerTest { ?.readAllBytes() .toString() .split("\n") + .mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } val indexer by lazy { System.gc() @@ -120,31 +121,31 @@ class HintIndexerTest { @Test fun runProbExistingKeys() = assert99PercentSucess { - indexer.getKey(keys.random()).isNotEmpty() + indexer.hintsForKey(keys.random()).isNotEmpty() } @Test fun runProbNewKeys() = assert99PercentSucess { - indexer.getKey(RandomInstance.bytes(32)).isEmpty() + indexer.hintsForKey(RandomInstance.bytes(32)).isEmpty() } @Test fun runProbExistingEventIds() = assert99PercentSucess { - indexer.getEvent(eventIds.random()).isNotEmpty() + indexer.hintsForEvent(eventIds.random()).isNotEmpty() } @Test fun runProbNewEventIds() = assert99PercentSucess { - indexer.getEvent(RandomInstance.bytes(32)).isEmpty() + indexer.hintsForEvent(RandomInstance.bytes(32)).isEmpty() } @Test fun runProbExistingAddresses() = assert99PercentSucess { - indexer.getAddress(addresses.random()).isNotEmpty() + indexer.hintsForAddress(addresses.random()).isNotEmpty() } @Test @@ -157,6 +158,6 @@ class HintIndexerTest { randomChars(10), ) - indexer.getAddress(newAddress).isEmpty() + indexer.hintsForAddress(newAddress).isEmpty() } } diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/hints/MurMur3Test.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/hints/MurMur3Test.kt index 195099cb41..dc9ac38ea5 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/hints/MurMur3Test.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/hints/MurMur3Test.kt @@ -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 diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/jackson/EventDeserializerTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/jackson/EventDeserializerTest.kt new file mode 100644 index 0000000000..cb22c26ba9 --- /dev/null +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/jackson/EventDeserializerTest.kt @@ -0,0 +1,65 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.jackson + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.verify +import org.junit.Test + +class EventDeserializerTest { + @Test + fun testEventTemplateToJson() { + val templateJson = """{"created_at":1234,"kind":1,"tags":[],"content":"This is an unsigned event."}""" + val template = EventTemplate.fromJson(templateJson) + + val json = template.toJson() + + assert(json == templateJson) + } + + @Test + fun testEventTemplate() { + val templateJson = """{"kind":"1","content":"This is an unsigned event.","created_at":1234,"tags":[]}""" + val template = EventTemplate.fromJson(templateJson) + + assert(template.kind == 1) + assert(template.content == "This is an unsigned event.") + assert(template.tags.isEmpty()) + } + + @Test + fun testSignedEvent() { + val keyPair = KeyPair() + val signer = NostrSignerInternal(keyPair) + + val templateJson = """{"kind":"1","content":"This is an unsigned event.","created_at":1234,"tags":[]}""" + val template = EventTemplate.fromJson(templateJson) + + val signedEvent = signer.signerSync.sign(template)!! + + assert(signedEvent.id.isNotEmpty()) + assert(signedEvent.sig.isNotEmpty()) + assert(signedEvent.pubKey.isNotEmpty()) + assert(signedEvent.verify()) + } +} diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/jackson/InliningTagArrayPrettyPrinterTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/jackson/InliningTagArrayPrettyPrinterTest.kt index f8d071fed9..39f7bef06c 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/jackson/InliningTagArrayPrettyPrinterTest.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/jackson/InliningTagArrayPrettyPrinterTest.kt @@ -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 diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/metadata/UpdateMetadataTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/metadata/UpdateMetadataTest.kt index 563dcd0816..674d72bb02 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/metadata/UpdateMetadataTest.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/metadata/UpdateMetadataTest.kt @@ -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,6 +20,9 @@ */ package com.vitorpamplona.quartz.nip01Core.metadata +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.jackson.EventManualSerializer +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper import com.vitorpamplona.quartz.utils.nsecToSigner import org.junit.Assert.assertEquals import org.junit.Test @@ -27,6 +30,14 @@ import org.junit.Test class UpdateMetadataTest { val signer = "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToSigner() + /** + * For debug purposes only + */ + fun Event.toPrettyJson(): String { + val obj = EventManualSerializer.assemble(id, pubKey, createdAt, kind, tags, content, sig) + return JsonMapper.mapper.writerWithDefaultPrettyPrinter().writeValueAsString(obj) + } + @Test fun createNewMetadata() { val test = signer.sign(MetadataEvent.createNew("Vitor", createdAt = 1740669816)) diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/relay/RelayUrlFormatterTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/relay/RelayUrlFormatterTest.kt new file mode 100644 index 0000000000..fd936867ac --- /dev/null +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/relay/RelayUrlFormatterTest.kt @@ -0,0 +1,47 @@ +/** + * 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.quartz.nip01Core.relay + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import junit.framework.TestCase.assertEquals +import org.junit.Test + +class RelayUrlFormatterTest { + @Test + fun format() { + assertEquals("wss://nostr.mom/", RelayUrlNormalizer.normalizeOrNull("wss://nostr.mom")?.url) + assertEquals("wss://nostr.mom/", RelayUrlNormalizer.normalizeOrNull("nostr.mom")?.url) + assertEquals("ws://nostr.mom/", RelayUrlNormalizer.normalizeOrNull("ws://nostr.mom")?.url) + assertEquals("wss://nostr.mom/", RelayUrlNormalizer.normalizeOrNull("wss://nostr.mom/")?.url) + assertEquals("wss://nostr.mom/", RelayUrlNormalizer.normalizeOrNull("https://nostr.mom/")?.url) + assertEquals("ws://nostr.mom/", RelayUrlNormalizer.normalizeOrNull("http://nostr.mom/")?.url) + + assertEquals("wss://localhost:3030/", RelayUrlNormalizer.normalizeOrNull("wss://localhost:3030")?.url) + assertEquals("ws://localhost:3030/", RelayUrlNormalizer.normalizeOrNull("localhost:3030")?.url) + + assertEquals("wss://a.onion/", RelayUrlNormalizer.normalizeOrNull("wss://a.onion")?.url) + assertEquals("ws://a.onion/", RelayUrlNormalizer.normalizeOrNull("a.onion")?.url) + assertEquals("wss://a.onion/", RelayUrlNormalizer.normalizeOrNull("wss://a.onion/")?.url) + assertEquals("ws://a.onion/", RelayUrlNormalizer.normalizeOrNull("a.onion/")?.url) + + assertEquals("wss://nostr.mom/", RelayUrlNormalizer.normalizeOrNull("wss://nostr.mom")?.url) + } +} diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventDbQueryAssemblerTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventDbQueryAssemblerTest.kt new file mode 100644 index 0000000000..78e75b1016 --- /dev/null +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventDbQueryAssemblerTest.kt @@ -0,0 +1,133 @@ +/** + * 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.quartz.nip01Core.store.sqlite + +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import junit.framework.TestCase +import org.junit.Test + +class EventDbQueryAssemblerTest { + val builder = EventIndexesModule(FullTextSearchModule()) + + val key1 = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d" + val key2 = "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14" + val key3 = "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9" + + @Test + fun testEmpty() { + val sql = builder.planQuery(Filter()) + TestCase.assertEquals( + "SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY created_at DESC, id", + sql, + ) + } + + @Test + fun testLimit() { + val sql = builder.planQuery(Filter(limit = 10)) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN (SELECT event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY created_at DESC, id ASC LIMIT 10) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + """.trimIndent(), + sql, + ) + } + + @Test + fun testAllFearures() { + val sql = + builder.planQuery( + listOf( + Filter(limit = 10), + Filter(authors = listOf(key1), kinds = listOf(1, 1111), search = "keywords", limit = 100), + Filter(kinds = listOf(20), search = "cats", limit = 30), + ), + ) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN (SELECT event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY created_at DESC, id ASC LIMIT 10 UNION SELECT event_headers.row_id as row_id FROM event_headers INNER JOIN event_fts ON event_fts.event_header_row_id = event_headers.row_id WHERE (event_headers.kind IN (?, ?)) AND (event_headers.pubkey = ?) AND (event_fts MATCH ?) ORDER BY created_at DESC, id ASC LIMIT 100 UNION SELECT event_headers.row_id as row_id FROM event_headers INNER JOIN event_fts ON event_fts.event_header_row_id = event_headers.row_id WHERE (event_headers.kind = ?) AND (event_fts MATCH ?) ORDER BY created_at DESC, id ASC LIMIT 30) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + """.trimIndent(), + sql, + ) + } + + @Test + fun testIdQuery() { + val sql = builder.planQuery(Filter(ids = listOf(key1))) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN (SELECT event_headers.row_id as row_id FROM event_headers WHERE event_headers.id = ?) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + """.trimIndent(), + sql, + ) + } + + @Test + fun testAuthors() { + val sql = builder.planQuery(Filter(authors = listOf(key1, key2))) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN (SELECT event_headers.row_id as row_id FROM event_headers WHERE event_headers.pubkey IN (?, ?)) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + """.trimIndent(), + sql, + ) + } + + @Test + fun testAuthorsAndSearch() { + val sql = builder.planQuery(Filter(authors = listOf(key1, key2, key3), search = "keywords")) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN (SELECT event_headers.row_id as row_id FROM event_headers INNER JOIN event_fts ON event_fts.event_header_row_id = event_headers.row_id WHERE (event_headers.pubkey IN (?, ?, ?)) AND (event_fts MATCH ?)) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + """.trimIndent(), + sql, + ) + } + + @Test + fun testKindAndSearch() { + val sql = builder.planQuery(Filter(kinds = listOf(1, 1111, 10000), search = "keywords")) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN (SELECT event_headers.row_id as row_id FROM event_headers INNER JOIN event_fts ON event_fts.event_header_row_id = event_headers.row_id WHERE (event_headers.kind IN (?, ?, ?)) AND (event_fts MATCH ?)) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + """.trimIndent(), + sql, + ) + } +} diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt index 7d3de8ccb4..675789e6fa 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt @@ -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 diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt index 1edbd57e02..c4609ba934 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt @@ -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,12 +20,15 @@ */ package com.vitorpamplona.quartz.nip19Bech32 +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub -import com.vitorpamplona.quartz.nip19Bech32.entities.Note import org.junit.Assert import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull @@ -62,7 +65,7 @@ class NIP19ParserTest { @Test() fun uri_to_route_note() { val result = - Nip19Parser.uriToRoute("nostr:note1stqea6wmwezg9x6yyr6qkukw95ewtdukyaztycws65l8wppjmtpscawevv")?.entity as? Note + Nip19Parser.uriToRoute("nostr:note1stqea6wmwezg9x6yyr6qkukw95ewtdukyaztycws65l8wppjmtpscawevv")?.entity as? NNote assertNotNull(result) Assert.assertEquals( @@ -106,7 +109,7 @@ class NIP19ParserTest { Assert.assertNotNull(actual) Assert.assertTrue(actual?.entity is NProfile) Assert.assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", (actual?.entity as? NProfile)?.hex) - Assert.assertEquals("wss://vitor.nostr1.com/", (actual?.entity as? NProfile)?.relay?.first()) + Assert.assertEquals(NormalizedRelayUrl("wss://vitor.nostr1.com/"), (actual?.entity as? NProfile)?.relay?.first()) } @Test() @@ -168,7 +171,7 @@ class NIP19ParserTest { "30023:d1e60465c2b777325e9133f2100d2bb31416dca810f54a1d95665621c5dee193:89de7920", (result?.entity as? NAddress)?.aTag(), ) - assertEquals("wss://relay.damus.io", (result?.entity as? NAddress)?.relay?.get(0)) + assertEquals(NormalizedRelayUrl("wss://relay.damus.io/"), (result?.entity as? NAddress)?.relay?.get(0)) } @Test @@ -176,7 +179,7 @@ class NIP19ParserTest { val address = ATag.parse( "30023:d1e60465c2b777325e9133f2100d2bb31416dca810f54a1d95665621c5dee193:89de7920", - "wss://relay.damus.io", + "relay.damus.io", ) assertEquals(30023, address?.kind) assertEquals( @@ -184,9 +187,9 @@ class NIP19ParserTest { address?.pubKeyHex, ) assertEquals("89de7920", address?.dTag) - assertEquals("wss://relay.damus.io", address?.relay) + assertEquals("wss://relay.damus.io/", address?.relay?.url) assertEquals( - "naddr1qqyrswtyv5mnjv3sqy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsygx3uczxts4hwue9ayfn7ggq62anzstde2qs749pm9tx2csuthhpjvpsgqqqw4rs8pmj38", + "naddr1qqyrswtyv5mnjv3sqy2hwumn8ghj7un9d3shjtnyv9kh2uewd9hj7q3q68nqgewzkamnyh53x0epqrftkv2pdh9gzr6558v4vetzr3w7uxfsxpqqqp65wmpxfdu", address?.toNAddr(), ) } @@ -228,10 +231,10 @@ class NIP19ParserTest { 30023, "d1e60465c2b777325e9133f2100d2bb31416dca810f54a1d95665621c5dee193", "89de7920", - "wss://relay.damus.io", + RelayUrlNormalizer.normalizeOrNull("wss://relay.damus.io")!!, ) assertEquals( - "naddr1qqyrswtyv5mnjv3sqy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsygx3uczxts4hwue9ayfn7ggq62anzstde2qs749pm9tx2csuthhpjvpsgqqqw4rs8pmj38", + "naddr1qqyrswtyv5mnjv3sqy2hwumn8ghj7un9d3shjtnyv9kh2uewd9hj7q3q68nqgewzkamnyh53x0epqrftkv2pdh9gzr6558v4vetzr3w7uxfsxpqqqp65wmpxfdu", address.toNAddr(), ) } @@ -297,7 +300,7 @@ class NIP19ParserTest { assertNotNull(result) assertEquals("d768c1b28eb94c7d90aa6b9152021fb69c8e34b452f870e2e42341ea7f9796ca", result?.hex) - assertEquals("wss://relay.nostr.bg/", result?.relay?.firstOrNull()) + assertEquals("wss://relay.nostr.bg/", result?.relay?.firstOrNull()?.url) assertEquals("cac0e43235806da094f0787a5b04e29ad04cb1a3c7ea5cf61edc1c338734082b", result?.author) assertEquals(1, result?.kind) } @@ -321,7 +324,7 @@ class NIP19ParserTest { assertNotNull(result) assertEquals("9677aa74676757cafad5910f46a1a35f58f7bae253b03eb8ffa70db2fb4643ea", result?.hex) - assertEquals("wss://relay.westernbtc.com", result?.relay?.firstOrNull()) + assertEquals("wss://relay.westernbtc.com/", result?.relay?.firstOrNull()?.url) assertEquals(null, result?.author) assertEquals(null, result?.kind) } @@ -336,7 +339,10 @@ class NIP19ParserTest { assertNotNull(result) assertEquals("b60ffa7256d3dd7543d830eb717ae50d05a6c32c5f791ed15b867c2bb0b954ac", result?.hex) - assertEquals("wss://nostr.mom", result?.relay?.get(0)) + assertEquals( + NormalizedRelayUrl("wss://nostr.mom/"), + result?.relay?.get(0), + ) assertEquals("f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", result?.author) assertEquals(1, result?.kind) } @@ -351,7 +357,7 @@ class NIP19ParserTest { assertNotNull(result) assertEquals("1f878e82063d80f41a781d3a2ef7bc336f1beb7942bf3b49b42aee1251eb5cf0", result?.hex) - assertEquals("wss://relay.damus.io", result?.relay?.get(0)) + assertEquals(NormalizedRelayUrl("wss://relay.damus.io/"), result?.relay?.get(0)) assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author) assertEquals(1, result?.kind) } @@ -361,14 +367,14 @@ class NIP19ParserTest { val result = Nip19Parser .uriToRoute( - "nostr:nevent1qqsg6gechd3dhzx38n4z8a2lylzgsmmgeamhmtzz72m9ummsnf0xjfspsdmhxue69uhkummn9ekx7mpvwaehxw309ahx7um5wghx77r5wghxgetk93mhxue69uhhyetvv9ujumn0wd68ytnzvuk8wumn8ghj7mn0wd68ytn9d9h82mny0fmkzmn6d9njuumsv93k2trhwden5te0wfjkccte9ehx7um5wghxyctwvsk8wumn8ghj7un9d3shjtnyv9kh2uewd9hs3kqsdn", + "nostr:nevent1qqsg6gechd3dhzx38n4z8a2lylzgsmmgeamhmtzz72m9ummsnf0xjfsppemhxue69uhkummn9ekx7mp0qy2hwumn8ghj7mn0wd68ytn00p68ytnyv4mz7qg4waehxw309aex2mrp0yhxummnw3ezucn89uqjqamnwvaz7tmwdaehgu3wv45kuatwv3a8wctw0f5kwtnnwpskxef0qythwumn8ghj7un9d3shjtnwdaehgu3wvfskuep0qy2hwumn8ghj7un9d3shjtnyv9kh2uewd9hj7dyp4wy", )?.entity as? NEvent assertNotNull(result) assertEquals("8d2338bb62db88d13cea23f55f27c4886f68cf777dac42f2b65e6f709a5e6926", result?.hex) assertEquals( - "wss://nos.lol,wss://nostr.oxtr.dev,wss://relay.nostr.bg,wss://nostr.einundzwanzig.space,wss://relay.nostr.band,wss://relay.damus.io", - result?.relay?.joinToString(","), + "wss://nos.lol/,wss://nostr.oxtr.dev/,wss://relay.nostr.bg/,wss://nostr.einundzwanzig.space/,wss://relay.nostr.band/,wss://relay.damus.io/", + result?.relay?.joinToString(",") { it.url }, ) } @@ -382,7 +388,7 @@ class NIP19ParserTest { assertNotNull(result) assertEquals("4300ec7fa2f98a276f033908349651620aa8e282b76030ab22abca63e85e07e6", result?.hex) - assertEquals("wss://relay.damus.io", result?.relay?.get(0)) + assertEquals("wss://relay.damus.io/", result?.relay?.get(0)?.url) assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author) assertEquals(1, result?.kind) } @@ -421,11 +427,22 @@ class NIP19ParserTest { "1f878e82063d80f41a781d3a2ef7bc336f1beb7942bf3b49b42aee1251eb5cf0", "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", 1, - "wss://relay.damus.io", + RelayUrlNormalizer.normalizeOrNull("wss://relay.damus.io"), ) assertEquals( - "nevent1qqsplpuwsgrrmq85rfup6w3w777rxmcmadu590emfx6z4msj2844euqpz3mhxue69uhhyetvv9ujuerpd46hxtnfdupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqvzqqqqqqye3a70w", + "nevent1qqsplpuwsgrrmq85rfup6w3w777rxmcmadu590emfx6z4msj2844euqpz4mhxue69uhhyetvv9ujuerpd46hxtnfduhsygzxpsj7dqha57pjk5k37gkn6g4nzakewtmqmnwryyhd3jfwlpgxtspsgqqqqqqsqvc0ku", nevent, ) } + + @Test + fun decodeBech32WithInvisibleCharacter() { + val bomChar = '\uFEFF' + val withBom = bomChar + "nsec1lfkarc7439n4l3uahr45ej8mrjc39dd879t0ps355550dj8j9uzs3rnw24" + + assertEquals( + "nsec1lfkarc7439n4l3uahr45ej8mrjc39dd879t0ps355550dj8j9uzs3rnw24", + withBom.bechToBytes().toNsec(), + ) + } } diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip19Bech32/TlvIntegerTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip19Bech32/TlvIntegerTest.kt index 8f7e3eab43..7255da0248 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/nip19Bech32/TlvIntegerTest.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip19Bech32/TlvIntegerTest.kt @@ -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 diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip30CustomEmoji/Nip30Test.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip30CustomEmoji/Nip30Test.kt index cf6aeca413..6ca46f02d4 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/nip30CustomEmoji/Nip30Test.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip30CustomEmoji/Nip30Test.kt @@ -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 diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestTest.kt new file mode 100644 index 0000000000..9202392cbd --- /dev/null +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestTest.kt @@ -0,0 +1,35 @@ +/** + * 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.quartz.nip46RemoteSigner + +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper +import org.junit.Test + +class BunkerRequestTest { + @Test + fun testBunkerRequestDeSerialization() { + val requestJson = """{"id":"123","method":"sign_event","params":["{\"created_at\":1234,\"kind\":1,\"tags\":[],\"content\":\"This is an unsigned event.\"}"]}""" + val bunkerRequest = JsonMapper.mapper.readValue(requestJson, BunkerRequest::class.java) + + assert(bunkerRequest is BunkerRequestSign) + assert((bunkerRequest as BunkerRequestSign).event.kind == 1) + } +} diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip51Lists/TagArrayExt.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip51Lists/TagArrayExt.kt new file mode 100644 index 0000000000..b5c40011e7 --- /dev/null +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip51Lists/TagArrayExt.kt @@ -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.quartz.nip51Lists + +import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId +import com.vitorpamplona.quartz.experimental.ephemChat.list.tags.RoomIdTag +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import junit.framework.TestCase.assertTrue +import org.junit.Test +import java.util.Arrays + +class TagArrayExt { + val tags = + arrayOf( + arrayOf("group", "test", "wss://nos.lol"), + arrayOf("group", "_", "wss://nos.lol"), + ) + + val expectedTags = + arrayOf( + arrayOf("group", "test", "wss://nos.lol"), + ) + + @Test + fun testRemove() { + assertTrue( + Arrays.deepEquals(expectedTags, tags.remove(arrayOf("group", "_", "wss://nos.lol"))), + ) + } + + @Test + fun testRemoveParsing() { + val removing = RoomId("_", RelayUrlNormalizer.normalize("wss://nos.lol")) + assertTrue( + Arrays.deepEquals( + expectedTags, + tags.removeParsing(RoomIdTag::parse, removing), + ), + ) + } +} diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip65RelayList/RelayListRecommendationProcessorTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip65RelayList/RelayListRecommendationProcessorTest.kt index 3590b316ef..b90ade3333 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/nip65RelayList/RelayListRecommendationProcessorTest.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip65RelayList/RelayListRecommendationProcessorTest.kt @@ -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,29 +20,32 @@ */ package com.vitorpamplona.quartz.nip65RelayList +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertTrue import org.junit.Test class RelayListRecommendationProcessorTest { + fun norm(str: String) = RelayUrlNormalizer.normalizeOrNull(str)!! + val userList = mutableMapOf( - "User1" to mutableSetOf("wss://relay1.com", "wss://relay2.com", "wss://relay3.com"), - "User2" to mutableSetOf("wss://relay4.com", "wss://relay5.com", "wss://relay6.com"), - "User3" to mutableSetOf("wss://relay1.com", "wss://relay4.com", "wss://relay6.com"), - "User4" to mutableSetOf("wss://relay2.com", "wss://relay1.com", "wss://relay4.com"), + "User1" to mutableSetOf(norm("wss://relay1.com"), norm("wss://relay2.com"), norm("wss://relay3.com")), + "User2" to mutableSetOf(norm("wss://relay4.com"), norm("wss://relay5.com"), norm("wss://relay6.com")), + "User3" to mutableSetOf(norm("wss://relay1.com"), norm("wss://relay4.com"), norm("wss://relay6.com")), + "User4" to mutableSetOf(norm("wss://relay2.com"), norm("wss://relay1.com"), norm("wss://relay4.com")), ) @Test fun testTranspose() { assertEquals( mapOf( - "wss://relay1.com" to listOf("User1", "User3", "User4"), - "wss://relay2.com" to listOf("User1", "User4"), - "wss://relay3.com" to listOf("User1"), - "wss://relay4.com" to listOf("User2", "User3", "User4"), - "wss://relay5.com" to listOf("User2"), - "wss://relay6.com" to listOf("User2", "User3"), + norm("wss://relay1.com") to listOf("User1", "User3", "User4"), + norm("wss://relay2.com") to listOf("User1", "User4"), + norm("wss://relay3.com") to listOf("User1"), + norm("wss://relay4.com") to listOf("User2", "User3", "User4"), + norm("wss://relay5.com") to listOf("User2"), + norm("wss://relay6.com") to listOf("User2", "User3"), ).toString(), RelayListRecommendationProcessor.transpose(userList).toString(), ) @@ -54,7 +57,7 @@ class RelayListRecommendationProcessorTest { val rec1 = recommendations[0] - assertEquals("wss://relay1.com", rec1.url) + assertEquals("wss://relay1.com/", rec1.relay.url) assertEquals(true, rec1.requiredToNotMissEvents) assertTrue("User1" in rec1.users) assertTrue("User2" !in rec1.users) @@ -63,7 +66,7 @@ class RelayListRecommendationProcessorTest { val rec2 = recommendations[1] - assertEquals("wss://relay4.com", rec2.url) + assertEquals("wss://relay4.com/", rec2.relay.url) assertEquals(true, rec2.requiredToNotMissEvents) assertTrue("User1" !in rec2.users) assertTrue("User2" in rec2.users) @@ -72,7 +75,7 @@ class RelayListRecommendationProcessorTest { val rec3 = recommendations[2] - assertEquals("wss://relay2.com", rec3.url) + assertEquals("wss://relay2.com/", rec3.relay.url) assertEquals(false, rec3.requiredToNotMissEvents) assertTrue("User1" in rec3.users) assertTrue("User2" !in rec3.users) @@ -81,7 +84,7 @@ class RelayListRecommendationProcessorTest { val rec4 = recommendations[3] - assertEquals("wss://relay5.com", rec4.url) + assertEquals("wss://relay5.com/", rec4.relay.url) assertEquals(false, rec4.requiredToNotMissEvents) assertTrue("User1" !in rec4.users) assertTrue("User2" in rec4.users) diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip96FileStorage/Nip96Test.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip96FileStorage/Nip96Test.kt index aaa870e136..0266f5ba4c 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/nip96FileStorage/Nip96Test.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip96FileStorage/Nip96Test.kt @@ -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 diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/utils/HexEncodingTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/utils/HexEncodingTest.kt index 0ccda6f689..2ca733f3a3 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/utils/HexEncodingTest.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/utils/HexEncodingTest.kt @@ -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 diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/utils/RuntimeExt.kt b/quartz/src/test/java/com/vitorpamplona/quartz/utils/RuntimeExt.kt index f43acce2ec..b4d47698ab 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/utils/RuntimeExt.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/utils/RuntimeExt.kt @@ -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 diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/utils/SignerUtils.kt b/quartz/src/test/java/com/vitorpamplona/quartz/utils/SignerUtils.kt index dafde3c62f..bb6efefb0e 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/utils/SignerUtils.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/utils/SignerUtils.kt @@ -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 diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/utils/StringUtilsTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/utils/StringUtilsTest.kt index 54d772977a..5221b9947e 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/utils/StringUtilsTest.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/utils/StringUtilsTest.kt @@ -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 diff --git a/settings.gradle b/settings.gradle index 4208f4fde2..0a0b52faa4 100644 --- a/settings.gradle +++ b/settings.gradle @@ -10,7 +10,7 @@ pluginManagement { mavenCentral() gradlePluginPortal() maven { - url "https://jitpack.io" + url = "https://jitpack.io" content { includeModule 'com.github.UnifiedPush', 'android-connector' } @@ -23,8 +23,8 @@ dependencyResolutionManagement { repositories { google() mavenCentral() - maven { url "https://jitpack.io" } - maven { url "https://raw.githubusercontent.com/guardianproject/gpmaven/master" } + maven { url = "https://jitpack.io" } + maven { url = "https://raw.githubusercontent.com/guardianproject/gpmaven/master" } } } diff --git a/spotless/copyright.kt b/spotless/copyright.kt index f46bcc74d1..def534cf12 100644 --- a/spotless/copyright.kt +++ b/spotless/copyright.kt @@ -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