Merge branch 'main' of https://github.com/vitorpamplona/amethyst into upstream-main

# Conflicts:
#	gradle/libs.versions.toml
This commit is contained in:
KotlinGeekDev
2026-02-11 13:57:01 +01:00
2588 changed files with 7089 additions and 6070 deletions
+1 -1
View File
@@ -11,6 +11,6 @@
<option name="languageVersion" value="2.3" />
</component>
<component name="KotlinJpsPluginSettings">
<option name="version" value="2.3.0" />
<option name="version" value="2.3.10" />
</component>
</project>
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
+230
View File
@@ -0,0 +1,230 @@
# Amethyst Builder Skill
Build customized Amethyst Nostr clients for Android. Fork, rebrand, customize, and distribute your own version.
## Overview
[Amethyst](https://github.com/vitorpamplona/amethyst) is the premier Nostr client for Android. This skill enables you to:
- Create rebranded versions (custom name, package, icons)
- Build F-Droid-compatible releases (no Google Play dependencies)
- Add or modify features
- Sign and distribute APKs
## Prerequisites
### Required Tools
1. **Java 21** (via SDKMAN)
```bash
curl -s "https://get.sdkman.io" | bash
source "$HOME/.sdkman/bin/sdkman-init.sh"
sdk install java 21.0.5-tem
sdk use java 21.0.5-tem
```
2. **Android SDK**
- Command-line tools from https://developer.android.com/studio#command-line-tools-only
- Required components: build-tools, platform-tools, platforms;android-35
3. **Git** for cloning the repository
### Environment Setup
```bash
export ANDROID_HOME=$HOME/Android/Sdk
export PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin
export PATH=$PATH:$ANDROID_HOME/platform-tools
```
## Build Workflow
### 1. Clone the Repository
```bash
mkdir -p ~/projects/your-app-name
cd ~/projects/your-app-name
git clone https://github.com/vitorpamplona/amethyst.git .
```
### 2. Create Signing Key
```bash
keytool -genkeypair -v \
-keystore ./release-key.jks \
-alias your-app \
-keyalg RSA -keysize 2048 \
-validity 10000
```
Create `keystore.properties` in project root:
```properties
storeFile=release-key.jks
storePassword=your-password
keyAlias=your-app
keyPassword=your-password
```
### 3. Configure Signing
Add to `amethyst/build.gradle` inside the `android {}` block:
```gradle
def keystorePropertiesFile = rootProject.file("keystore.properties")
def keystoreProperties = new Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
signingConfigs {
release {
if (keystorePropertiesFile.exists()) {
storeFile rootProject.file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
}
}
}
```
Update the release buildType to use the signing config:
```gradle
buildTypes {
release {
signingConfig signingConfigs.release
// ... existing config
}
}
```
### 4. Disable Google Services (Required for F-Droid)
**⚠️ CRITICAL:** The Google Services plugin fails when you change the package name. For F-Droid builds, disable it.
Edit `amethyst/build.gradle`, comment out the plugin:
```gradle
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.jetbrainsKotlinAndroid)
// alias(libs.plugins.googleServices) // DISABLED for F-Droid
alias(libs.plugins.jetbrainsComposeCompiler)
}
```
### 5. Build
```bash
source "$HOME/.sdkman/bin/sdkman-init.sh"
sdk use java 21.0.5-tem
./gradlew assembleFdroidRelease
```
**Build time:** ~9 minutes first build, faster on subsequent builds.
### 6. Locate APKs
Output directory: `amethyst/build/outputs/apk/fdroid/release/`
Files generated:
- `amethyst-fdroid-arm64-v8a-release.apk` - ARM64 (most phones)
- `amethyst-fdroid-universal-release.apk` - All architectures (larger)
## Customizations
### Change App Name
Edit `amethyst/src/main/res/values/strings.xml`:
```xml
<string name="app_name" translatable="false">YourAppName</string>
<string name="app_name_debug" translatable="false">YourAppName Debug</string>
```
### Change Package ID
Edit `amethyst/build.gradle`:
```gradle
android {
defaultConfig {
applicationId = "com.yourcompany.yourapp"
}
}
```
### Change Project Name
Edit `settings.gradle`:
```gradle
rootProject.name = "YourAppName"
```
### Change App Icon
Replace icon files in:
- `amethyst/src/main/res/mipmap-*/ic_launcher.webp`
- `amethyst/src/main/res/mipmap-*/ic_launcher_round.webp`
### Add Client Tag to Posts
Make your app identify itself on posts with `["client", "YourAppName"]`.
**1. Create tag builder extension:**
Create `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/clientTag/TagArrayBuilderExt.kt`:
```kotlin
package com.vitorpamplona.quartz.nip01Core.tags.clientTag
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
fun <T : Event> TagArrayBuilder<T>.client(clientName: String) =
addUnique(arrayOf(ClientTag.TAG_NAME, clientName))
```
**2. Add to TextNoteEvent:**
Edit `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt`:
Add import:
```kotlin
import com.vitorpamplona.quartz.nip01Core.tags.clientTag.client
```
In both `build()` functions, add after `alt(...)`:
```kotlin
client("YourAppName")
```
### Modify Default Relays
Edit relay configuration in `quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/` or the UI settings files.
## Troubleshooting
### google-services.json error
Disable the Google Services plugin (see step 4).
### Java version error
```bash
sdk use java 21.0.5-tem
java -version # Must show 21.x
```
### Out of memory
Edit `gradle.properties`:
```properties
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m
```
### Clean build
```bash
./gradlew --stop
./gradlew clean
./gradlew assembleFdroidRelease
```
## Distribution
Deploy APKs via:
- **Surge.sh:** `surge ./releases your-app.surge.sh`
- **Zapstore:** Submit to zapstore.dev
- **Direct download:** Host on any web server
+1 -1
View File
@@ -2,7 +2,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.jetbrainsKotlinAndroid)
alias(libs.plugins.googleServices)
alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.serialization)
@@ -192,6 +191,7 @@ android {
buildFeatures {
compose = true
buildConfig = true
resValues = true
}
packagingOptions {
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -28,6 +28,7 @@ import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.actions.buildAnnotatedStringWithUrlHighlighting
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey
import org.junit.Assert.assertEquals
@@ -77,8 +78,19 @@ class UrlUserTagTransformationTest {
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
.toHexKey(),
)
user.info = UserMetadata()
user.info?.displayName = "Vitor Pamplona"
user.metadata().newMetadata(
UserMetadata().also {
it.displayName = "Vitor Pamplona"
},
MetadataEvent(
id = "",
pubKey = "",
createdAt = 0,
tags = emptyArray(),
content = "",
sig = "",
),
)
val original = "@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"
@@ -185,8 +197,19 @@ class UrlUserTagTransformationTest {
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
.toHexKey(),
)
user.info = UserMetadata()
user.info?.displayName = "Vitor Pamplona"
user.metadata().newMetadata(
UserMetadata().also {
it.displayName = "Vitor Pamplona"
},
MetadataEvent(
id = "",
pubKey = "",
createdAt = 0,
tags = emptyArray(),
content = "",
sig = "",
),
)
val transformedText =
buildAnnotatedStringWithUrlHighlighting(
@@ -224,8 +247,20 @@ class UrlUserTagTransformationTest {
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
.toHexKey(),
)
user.info = UserMetadata()
user.info?.displayName = "Vitor Pamplona"
user.metadata().newMetadata(
UserMetadata().also {
it.displayName = "Vitor Pamplona"
},
MetadataEvent(
id = "",
pubKey = "",
createdAt = 0,
tags = emptyArray(),
content = "",
sig = "",
),
)
val transformedText =
buildAnnotatedStringWithUrlHighlighting(
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -26,10 +26,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.MaxWidthPaddingTop5dp
import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists
@Composable
fun TranslatableRichTextViewer(
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -65,6 +65,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats.RelayReqStats
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
import com.vitorpamplona.quartz.nip03Timestamp.ots.OtsBlockHeightCache
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
import com.vitorpamplona.quartz.nip05DnsIdentifiers.OkHttpNip05Fetcher
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
@@ -137,6 +139,10 @@ class AppModules(
// Offers easy methods to know when connections are happening through Tor or not
val roleBasedHttpClientBuilder = RoleBasedHttpClientBuilder(okHttpClients, torPrefs.value)
// Custom fetcher that considers tor settings and avoids forwarding.
val nip05Fetcher = OkHttpNip05Fetcher(roleBasedHttpClientBuilder::okHttpClientForNip05)
val nip05Client = Nip05Client(nip05Fetcher)
// Application-wide block height request cache
val otsBlockHeightCache by lazy { OtsBlockHeightCache() }
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -98,9 +98,7 @@ fun debugState(context: Context) {
Log.d(
STATE_DUMP_TAG,
"Users: " +
LocalCache.users.filter { _, it -> it.flowSet != null }.size +
" / " +
LocalCache.users.filter { _, it -> it.latestMetadata != null }.size +
LocalCache.users.filter { _, it -> it.metadataOrNull() != null }.size +
" / " +
LocalCache.users.size(),
)
@@ -147,10 +145,8 @@ fun debugState(context: Context) {
)
Log.d(
STATE_DUMP_TAG,
"Observable Events: " +
LocalCache.observablesByKindAndETag.size +
" / " +
LocalCache.observablesByKindAndAuthor.size,
"Observables: " +
LocalCache.observables.size,
)
Log.d(
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -250,7 +250,7 @@ class Account(
val userMetadata = UserMetadataState(signer, cache, scope, settings)
override val nip47SignerState = NwcSignerState(signer, nwcFilterAssembler, cache, scope, settings)
override val nip47SignerState = NwcSignerState(signer, nwcFilterAssembler, cache, scope, settings.zapPaymentRequest)
val nip65RelayList = Nip65RelayListState(signer, cache, scope, settings)
val localRelayList = LocalRelayListState(signer, cache, scope, settings)
@@ -740,7 +740,7 @@ class Account(
outboxRelays.flow.value
} else {
replyToAuthor.inboxRelays()?.ifEmpty { null }?.toSet()
?: replyToAuthor.relaysBeingUsed.keys.ifEmpty { null }
?: replyToAuthor.allUsedRelaysOrNull()
?: cache.relayHints
.hintsForKey(replyToAuthor.pubkeyHex)
.ifEmpty { null }
@@ -767,7 +767,7 @@ class Account(
notificationRelays.flow.value
} else {
user.inboxRelays()?.ifEmpty { null }?.toSet()
?: (cache.relayHints.hintsForKey(user.pubkeyHex).toSet() + user.relaysBeingUsed.keys)
?: (cache.relayHints.hintsForKey(user.pubkeyHex).toSet() + user.allUsedRelays())
}
private fun computeRelayListForLinkedUser(pubkey: HexKey): Set<NormalizedRelayUrl> =
@@ -822,7 +822,7 @@ class Account(
} else {
val relays =
author.outboxRelays()?.ifEmpty { null }
?: author.relaysBeingUsed.keys.ifEmpty { null }
?: author.allUsedRelaysOrNull()
?: cache.relayHints.hintsForKey(author.pubkeyHex)
relayList.addAll(relays)
@@ -1741,7 +1741,7 @@ class Account(
val relayList =
dvmPublicKey.inboxRelays()?.toSet()?.ifEmpty { null }
?: (dvmPublicKey.relaysBeingUsed.keys + cache.relayHints.hintsForKey(dvmPublicKey.pubkeyHex))
?: (dvmPublicKey.allUsedRelays() + cache.relayHints.hintsForKey(dvmPublicKey.pubkeyHex))
cache.justConsumeMyOwnEvent(request)
onReady(request)
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -81,9 +81,9 @@ val DefaultNIP65List =
val DefaultDMRelayList = listOf(Constants.auth, Constants.oxchat, Constants.nos)
val DefaultSearchRelayList = setOf(Constants.band, Constants.wine, Constants.where, Constants.nostoday)
val DefaultSearchRelayList = setOf(Constants.wine, Constants.where, Constants.nostoday, Constants.antiprimal)
val DefaultIndexerRelayList = setOf(Constants.purplepages, Constants.coracle, Constants.userkinds)
val DefaultIndexerRelayList = setOf(Constants.purplepages, Constants.coracle, Constants.userkinds, Constants.yabu, Constants.nostr1)
val DefaultSignerPermissions =
listOf(
@@ -122,7 +122,7 @@ class AccountSettings(
val defaultStoriesFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
val defaultNotificationFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
val defaultDiscoveryFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
var zapPaymentRequest: MutableStateFlow<Nip47WalletConnect.Nip47URINorm?> = MutableStateFlow(null),
val zapPaymentRequest: MutableStateFlow<Nip47WalletConnect.Nip47URINorm?> = MutableStateFlow(null),
var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false,
var hideNIP17WarningDialog: Boolean = false,
@@ -144,7 +144,7 @@ class AccountSettings(
var backupEphemeralChatList: EphemeralChatListEvent? = null,
var backupTrustProviderList: TrustProviderListEvent? = null,
val lastReadPerRoute: MutableStateFlow<Map<String, MutableStateFlow<Long>>> = MutableStateFlow(mapOf()),
var hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf<String>()),
val hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf<String>()),
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow<Map<HexKey, String>>(mapOf()),
var backupNipA3PaymentTargets: PaymentTargetsEvent? = null,
) : EphemeralChatRepository,
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -28,7 +28,6 @@ object Constants {
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")
@@ -37,6 +36,7 @@ object Constants {
val oxtr = RelayUrlNormalizer.normalize("wss://nostr.oxtr.dev")
val nostoday = RelayUrlNormalizer.normalize("wss://search.nos.today")
val antiprimal = RelayUrlNormalizer.normalize("wss://antiprimal.net")
val auth = RelayUrlNormalizer.normalize("wss://auth.nostr1.com")
val oxchat = RelayUrlNormalizer.normalize("wss://relay.0xchat.com")
@@ -44,9 +44,9 @@ object Constants {
val purplepages = RelayUrlNormalizer.normalize("wss://purplepag.es")
val coracle = RelayUrlNormalizer.normalize("wss://indexer.coracle.social")
val userkinds = RelayUrlNormalizer.normalize("wss://user.kindpag.es")
val yabu = RelayUrlNormalizer.normalize("wss://directory.yabu.me")
val nostr1 = RelayUrlNormalizer.normalize("wss://profiles.nostr1.com")
val bootstrapInbox = setOf(band, damus, primal, mom, nos, bitcoiner, oxtr)
val eventFinderRelays = setOf(band, wine, damus, primal, mom, nos, bitcoiner, oxtr)
val defaultSearchRelaySet = setOf(band, wine, where)
val bootstrapInbox = setOf(damus, primal, mom, nos, bitcoiner, oxtr, yabu)
val eventFinderRelays = setOf(wine, damus, primal, mom, nos, bitcoiner, oxtr)
}
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.commons.hashtags.Skull
import com.vitorpamplona.amethyst.commons.hashtags.Tunestr
import com.vitorpamplona.amethyst.commons.hashtags.Weed
import com.vitorpamplona.amethyst.commons.hashtags.Zap
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
import com.vitorpamplona.amethyst.ui.components.HashTag
@@ -52,7 +53,6 @@ import com.vitorpamplona.amethyst.ui.components.RenderRegular
import com.vitorpamplona.amethyst.ui.components.RenderTextParagraph
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
@Preview
@Composable
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -28,12 +28,14 @@ import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtIdHexComparator
import com.vitorpamplona.amethyst.commons.model.observables.EventListMatchingFilter
import com.vitorpamplona.amethyst.commons.model.observables.NoteListMatchingFilter
import com.vitorpamplona.amethyst.commons.model.observables.Observable
import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList
import com.vitorpamplona.amethyst.commons.services.nwc.NwcPaymentTracker
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
import com.vitorpamplona.amethyst.model.observables.LatestByKindAndAuthor
import com.vitorpamplona.amethyst.model.observables.LatestByKindWithETag
import com.vitorpamplona.amethyst.service.BundledInsert
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.dateFormatter
@@ -62,6 +64,9 @@ import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
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.isAddressable
import com.vitorpamplona.quartz.nip01Core.core.isRegular
import com.vitorpamplona.quartz.nip01Core.core.isReplaceable
import com.vitorpamplona.quartz.nip01Core.core.tagValueContains
import com.vitorpamplona.quartz.nip01Core.crypto.checkSignature
import com.vitorpamplona.quartz.nip01Core.crypto.verify
@@ -72,13 +77,12 @@ 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.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.aTag.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.taggedEvents
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUsers
@@ -129,7 +133,6 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
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
import com.vitorpamplona.quartz.nip40Expiration.isExpired
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
@@ -194,19 +197,22 @@ import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.util.SortedSet
import java.util.concurrent.ConcurrentHashMap
interface ILocalCache {
@@ -234,77 +240,104 @@ object LocalCache : ILocalCache, ICacheProvider {
val deletionIndex = DeletionIndex()
val observablesByKindAndETag = ConcurrentHashMap<Int, ConcurrentHashMap<HexKey, LatestByKindWithETag<Event>>>(10)
val observablesByKindAndAuthor = ConcurrentHashMap<Int, ConcurrentHashMap<HexKey, LatestByKindAndAuthor<Event>>>(10)
val observables = ConcurrentHashMap<Observable, Observable>(10)
fun <T : Event> observeETag(
kind: Int,
eventId: HexKey,
scope: CoroutineScope,
): LatestByKindWithETag<T> {
var eTagList = observablesByKindAndETag.get(kind)
if (eTagList == null) {
eTagList = ConcurrentHashMap<HexKey, LatestByKindWithETag<T>>(1) as ConcurrentHashMap<HexKey, LatestByKindWithETag<Event>>
observablesByKindAndETag.put(kind, eTagList)
}
val value = eTagList.get(eventId)
return if (value != null) {
value
fun Filter.match(note: Note): Boolean {
val event = note.event
return if (event != null) {
match(event)
} else {
val newObject = LatestByKindWithETag<T>(kind, eventId) as LatestByKindWithETag<Event>
val obj = eTagList.putIfAbsent(eventId, newObject) ?: newObject
if (obj == newObject) {
// initialize
scope.launch(Dispatchers.IO) {
obj.init()
false
}
}
fun filter(filter: Filter): SortedSet<Note> {
val byKinds = filter.kinds?.filter { it.isAddressable() || it.isReplaceable() }
val addressableMatches =
if (!byKinds.isNullOrEmpty()) {
val byAuthors = filter.authors
if (!byAuthors.isNullOrEmpty()) {
// optimized
byKinds.flatMap { kind ->
byAuthors.flatMap { pubkey ->
addressables.filter(kind, pubkey) { _, note ->
filter.match(note)
}
}
}
} else {
// optimized
byKinds.flatMap { kind ->
addressables.filter(kind) { _, note ->
filter.match(note)
}
}
}
} else {
addressables.filter { _, note ->
filter.match(note)
}
}
obj
} as LatestByKindWithETag<T>
}
fun <T : Event> observeAuthor(
kind: Int,
pubkey: HexKey,
scope: CoroutineScope,
): LatestByKindAndAuthor<T> {
var authorObsList = observablesByKindAndAuthor.get(kind)
if (authorObsList == null) {
authorObsList = ConcurrentHashMap<HexKey, LatestByKindAndAuthor<T>>(1) as ConcurrentHashMap<HexKey, LatestByKindAndAuthor<Event>>
observablesByKindAndAuthor.put(kind, authorObsList)
}
val value = authorObsList.get(pubkey)
return if (value != null) {
value
} else {
val newObject = LatestByKindAndAuthor<T>(kind, pubkey) as LatestByKindAndAuthor<Event>
val obj = authorObsList.putIfAbsent(pubkey, newObject) ?: newObject
if (obj == newObject) {
// initialize
scope.launch(Dispatchers.IO) {
obj.init()
val noteMatches =
notes.filter { _, note ->
val event = note.event
if (event != null && event.kind.isRegular()) {
filter.match(event)
} else {
false
}
}
obj
} as LatestByKindAndAuthor<T>
}
private fun updateObservables(event: Event) {
observablesByKindAndETag[event.kind]?.let { observablesOfKind ->
event.forEachTaggedEventId {
observablesOfKind[it]?.updateIfMatches(event)
val limit = filter.limit
val limitedSet =
if (limit != null) {
(addressableMatches + noteMatches).take(limit)
} else {
(addressableMatches + noteMatches)
}
}
observablesByKindAndAuthor[event.kind]?.get(event.pubKey)?.updateIfMatches(event)
return limitedSet.toSortedSet(CreatedAtIdHexComparator)
}
fun observeNotes(filter: Filter): Flow<List<Note>> =
callbackFlow {
val newFilter =
NoteListMatchingFilter(filter, this@LocalCache::filter) {
trySend(it)
}
newFilter.init()
observables.put(newFilter, newFilter)
awaitClose {
observables.remove(newFilter)
}
}.buffer(kotlinx.coroutines.channels.Channel.CONFLATED)
fun observeEvents(filter: Filter): Flow<List<Event>> =
callbackFlow {
val cachedFilter =
EventListMatchingFilter(filter, this@LocalCache::filter) {
trySend(it)
}
cachedFilter.init()
observables.put(cachedFilter, cachedFilter)
awaitClose {
observables.remove(cachedFilter)
}
}.buffer(kotlinx.coroutines.channels.Channel.CONFLATED)
fun <T : Event> observeLatestEvent(filter: Filter) = observeEvents(filter).map { it.firstNotNullOfOrNull { it as? T } }
fun observeLatestNote(filter: Filter) = observeNotes(filter).map { it.firstOrNull() }
fun checkGetOrCreateUser(key: String): User? = runCatching { getOrCreateUser(key) }.getOrNull()
fun load(keys: List<String>): List<User> = keys.mapNotNull(::checkGetOrCreateUser)
@@ -334,6 +367,15 @@ object LocalCache : ILocalCache, ICacheProvider {
return count
}
fun countContactLists(predicate: (ContactListEvent) -> Boolean): Int {
var count = 0
addressables.filter(ContactListEvent.KIND).forEach { note ->
val event = note.event as? ContactListEvent
if (event != null && predicate(event)) count++
}
return count
}
fun getAddressableNoteIfExists(key: String): AddressableNote? = Address.parse(key)?.let { addressables.get(it) }
fun getAddressableNoteIfExists(address: Address): AddressableNote? = addressables.get(address)
@@ -490,20 +532,16 @@ object LocalCache : ILocalCache, ICacheProvider {
wasVerified: Boolean,
): Boolean {
// new event
val oldUser = getOrCreateUser(event.pubKey)
val currentMetadata = oldUser.latestMetadata
consumeBaseReplaceable(event, relay, wasVerified)
if (currentMetadata == null || event.createdAt > currentMetadata.createdAt) {
oldUser.latestMetadata = event
val user = getOrCreateUser(event.pubKey)
if (user.metadata().shouldUpdateWith(event)) {
val newUserMetadata = event.contactMetaData()
if (newUserMetadata != null && (wasVerified || justVerify(event))) {
oldUser.updateUserInfo(newUserMetadata, event)
user.updateUserInfo(newUserMetadata, event)
if (relay != null) {
oldUser.addRelayBeingUsed(relay, event.createdAt)
if (!relay.isLocalHost()) {
oldUser.latestMetadataRelay = relay
}
user.addRelayBeingUsed(relay, event.createdAt)
}
return true
@@ -517,25 +555,7 @@ object LocalCache : ILocalCache, ICacheProvider {
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() && (wasVerified || justVerify(event))) {
val needsToUpdateFollowers = user.updateContactList(event)
// Log.d("CL", "Consumed contact list ${user.toNostrUri()} ${event.relays()?.size}")
needsToUpdateFollowers.forEach {
getUserIfExists(it)?.flowSet?.followers?.invalidateData()
}
updateObservables(event)
return true
}
return false
}
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: BookmarkListEvent,
@@ -878,59 +898,101 @@ object LocalCache : ILocalCache, ICacheProvider {
@Suppress("DEPRECATION")
fun computeReplyTo(event: Event): List<Note> =
when (event) {
is PollNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
is LongTextNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
is GitReplyEvent -> event.tagsWithoutCitations().filter { it != event.repository()?.toTag() }.mapNotNull { checkGetOrCreateNote(it) }
is TextNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
is CommentEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
is PollNoteEvent -> {
event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
}
is VoiceReplyEvent -> event.markedReplyTos().mapNotNull { checkGetOrCreateNote(it) }
is LongTextNoteEvent -> {
event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
}
is ChatMessageEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) }
is ChatMessageEncryptedFileHeaderEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) }
is GitReplyEvent -> {
event.tagsWithoutCitations().filter { it != event.repository()?.toTag() }.mapNotNull { checkGetOrCreateNote(it) }
}
is LnZapEvent ->
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) }
}
is LnZapEvent -> {
event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) } +
(event.zapRequest?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptyList())
is LnZapRequestEvent ->
}
is LnZapRequestEvent -> {
event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
is BadgeProfilesEvent ->
}
is BadgeProfilesEvent -> {
event.badgeAwardEvents().mapNotNull { checkGetOrCreateNote(it) } +
event.badgeAwardDefinitions().map { getOrCreateAddressableNote(it) }
is BadgeAwardEvent -> event.awardDefinition().map { getOrCreateAddressableNote(it) }
is PrivateDmEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) }
is RepostEvent ->
}
is BadgeAwardEvent -> {
event.awardDefinition().map { getOrCreateAddressableNote(it) }
}
is PrivateDmEvent -> {
event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) }
}
is RepostEvent -> {
listOfNotNull(
event.boostedEventId()?.let { checkGetOrCreateNote(it) },
event.boostedAddress()?.let { getOrCreateAddressableNote(it) },
)
is GenericRepostEvent ->
}
is GenericRepostEvent -> {
listOfNotNull(
event.boostedEventId()?.let { checkGetOrCreateNote(it) },
event.boostedAddress()?.let { getOrCreateAddressableNote(it) },
)
is CommunityPostApprovalEvent ->
}
is CommunityPostApprovalEvent -> {
event.approvedEvents().mapNotNull { checkGetOrCreateNote(it) } +
event.approvedAddresses().map { getOrCreateAddressableNote(it) }
is ReactionEvent ->
}
is ReactionEvent -> {
event.originalPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
is ChannelMessageEvent ->
event
.tagsWithoutCitations()
.filter { it != event.channelId() }
.mapNotNull { checkGetOrCreateNote(it) }
is LiveActivitiesChatMessageEvent ->
event
.tagsWithoutCitations()
.filter { it != event.activity()?.toTag() }
.mapNotNull { checkGetOrCreateNote(it) }
is TorrentCommentEvent ->
event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
}
else -> emptyList()
is ChannelMessageEvent -> {
event.tagsWithoutCitations().filter { it != event.channelId() }.mapNotNull { checkGetOrCreateNote(it) }
}
is LiveActivitiesChatMessageEvent -> {
event.tagsWithoutCitations().filter { it != event.activity()?.toTag() }.mapNotNull { checkGetOrCreateNote(it) }
}
is TorrentCommentEvent -> {
event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
}
else -> {
emptyList()
}
}
fun consume(
@@ -1183,33 +1245,15 @@ object LocalCache : ILocalCache, ICacheProvider {
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
val note = event.toAddressableNote()
val new = consumeBaseReplaceable(event, relay, wasVerified)
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 false
if (event.createdAt > (note.createdAt() ?: 0L) && (isVerified || justVerify(event))) {
note.loadEvent(event, author, emptyList())
author.flowSet?.statuses?.invalidateData()
refreshNewNoteObservers(note)
return true
if (new) {
author.statusState().addStatus(note)
}
return false
return new
}
fun Event.toNote() = getOrCreateNote(id)
@@ -1472,6 +1516,10 @@ object LocalCache : ILocalCache, ICacheProvider {
getUserIfExists(deletedEvent.aboutUser())?.cardsOrNull()?.removeCard(deleteNote)
}
if (deleteNote is AddressableNote && deletedEvent is StatusEvent) {
deleteNote.author?.statusStateOrNull()?.removeStatus(deleteNote)
}
if (deletedEvent is TorrentCommentEvent) {
deletedEvent.torrentIds()?.let {
getNoteIfExists(it)?.removeReply(deleteNote)
@@ -1838,13 +1886,11 @@ object LocalCache : ILocalCache, ICacheProvider {
}
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)
@@ -1872,7 +1918,6 @@ object LocalCache : ILocalCache, ICacheProvider {
note.loadEvent(event, author, repliesTo)
repliesTo.forEach { it.addZap(note, null) }
mentions.forEach { it.addZap(note, null) }
refreshNewNoteObservers(note)
@@ -2125,7 +2170,7 @@ object LocalCache : ILocalCache, ICacheProvider {
val finds =
users.filter { _, user: User ->
(
(user.anyNameStartsWith(username)) ||
(user.metadataOrNull()?.anyNameStartsWith(username) == true) ||
user.pubkeyHex.startsWith(username, true) ||
user.pubkeyNpub().startsWith(username, true)
) &&
@@ -2154,7 +2199,9 @@ object LocalCache : ILocalCache, ICacheProvider {
note.event is ReactionEvent ||
note.event is LnZapEvent ||
note.event is LnZapRequestEvent ||
note.event is FileHeaderEvent
note.event is FileHeaderEvent ||
note.event is MetadataEvent ||
note.event is ContactListEvent
)
fun findNotesStartingWith(
@@ -2265,23 +2312,6 @@ object LocalCache : ILocalCache, ICacheProvider {
fun getPeopleListNotesFor(user: User): List<AddressableNote> = addressables.filter(PeopleListEvent.KIND, user.pubkeyHex)
fun findStatusesForUser(user: User): ImmutableList<AddressableNote> {
checkNotInMainThread()
return addressables
.filter { _, it ->
val noteEvent = it.event
(
noteEvent is StatusEvent &&
noteEvent.pubKey == user.pubkeyHex &&
!noteEvent.isExpired() &&
noteEvent.content.isNotBlank()
)
}.sortedWith(compareBy({ it.event?.expiration() ?: it.event?.createdAt }, { it.idHex }))
.reversed()
.toImmutableList()
}
suspend fun findEarliestOtsForNote(
note: Note,
otsVerifCache: VerificationStateCache,
@@ -2366,23 +2396,6 @@ object LocalCache : ILocalCache, ICacheProvider {
fun cleanObservers() {
notes.forEach { _, it -> it.clearFlow() }
addressables.forEach { _, it -> it.clearFlow() }
users.forEach { _, it -> it.clearFlow() }
observablesByKindAndETag.forEach { _, list ->
list.forEach { key, value ->
if (value.canDelete()) {
list.remove(key)
}
}
}
observablesByKindAndAuthor.forEach { _, list ->
list.forEach { key, value ->
if (value.canDelete()) {
list.remove(key)
}
}
}
}
fun pruneHiddenMessagesChannel(
@@ -2576,18 +2589,6 @@ object LocalCache : ILocalCache, ICacheProvider {
val noteEvent = note.event
if (noteEvent is LnZapEvent) {
noteEvent.zappedAuthor().forEach {
val author = getUserIfExists(it)
author?.removeZap(note)
}
}
if (noteEvent is LnZapRequestEvent) {
noteEvent.zappedAuthor().mapNotNull {
val author = getUserIfExists(it)
author?.removeZap(note)
}
}
if (noteEvent is ReportEvent) {
noteEvent.reportedAuthor().forEach {
getUserIfExists(it.pubkey)?.reportsOrNull()?.removeReport(note)
@@ -2601,10 +2602,15 @@ object LocalCache : ILocalCache, ICacheProvider {
getAddressableNoteIfExists(it.address)?.removeReport(note)
}
}
if (note is AddressableNote && noteEvent is ContactCardEvent) {
getUserIfExists(noteEvent.aboutUser())?.cardsOrNull()?.removeCard(note)
}
if (note is AddressableNote && noteEvent is StatusEvent) {
note.author?.statusStateOrNull()?.removeStatus(note)
}
note.clearFlow()
notes.remove(note.idHex)
@@ -2663,24 +2669,6 @@ object LocalCache : ILocalCache, ICacheProvider {
println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden")
}
fun pruneContactLists(loggedIn: Set<HexKey>) {
checkNotInMainThread()
var removingContactList = 0
users.forEach { _, user ->
if (
user.pubkeyHex !in loggedIn &&
(user.flowSet == null || user.flowSet?.isInUse() == false) &&
user.latestContactList != null
) {
user.latestContactList = null
removingContactList++
}
}
println("PRUNE: $removingContactList contact lists")
}
override fun markAsSeen(
eventId: String,
relay: NormalizedRelayUrl,
@@ -2701,11 +2689,23 @@ object LocalCache : ILocalCache, ICacheProvider {
private fun refreshNewNoteObservers(newNote: Note) {
val event = newNote.event as Event
updateObservables(event)
val observableBiConsumer =
java.util.function.BiConsumer<Observable, Observable> { t, u ->
u.new(event, newNote)
}
observables.forEach(observableBiConsumer)
live.newNote(newNote)
}
private fun refreshDeletedNoteObservers(newNote: Note) {
val observableBiConsumer =
java.util.function.BiConsumer<Observable, Observable> { t, u ->
u.remove(newNote)
}
observables.forEach(observableBiConsumer)
live.removedNote(newNote)
}
@@ -2733,27 +2733,38 @@ object LocalCache : ILocalCache, ICacheProvider {
fun consume(nip19: Entity) {
when (nip19) {
is NSec -> getOrCreateUser(nip19.toPubKeyHex())
is NPub -> getOrCreateUser(nip19.hex)
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 ->
@@ -2761,6 +2772,7 @@ object LocalCache : ILocalCache, ICacheProvider {
}
getOrCreateAddressableNote(nip19.address())
}
else -> { }
}
}
@@ -2810,7 +2822,7 @@ object LocalCache : ILocalCache, ICacheProvider {
return justConsumeAndUpdateIndexes(event, relay?.url, wasVerified)
}
private fun checkDeletionAndConsume(
fun checkDeletionAndConsume(
event: Event,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
@@ -3008,10 +3020,7 @@ object LocalCache : ILocalCache, ICacheProvider {
is VoiceReplyEvent -> consume(event, relay, wasVerified)
is WikiNoteEvent -> consume(event, relay, wasVerified)
is PaymentTargetsEvent -> consume(event, relay, wasVerified)
else -> {
Log.w("Event Not Supported", "From ${relay?.url}: ${event.toJson()}")
false
}
else -> Log.w("Event Not Supported", "From ${relay?.url}: ${event.toJson()}").let { false }
}
} catch (e: Exception) {
if (e is CancellationException) throw e
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.model
import android.util.LruCache
import androidx.collection.LruCache
interface MutableMediaAspectRatioCache {
fun get(url: String): Float?
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -168,6 +168,7 @@ fun parseResourceBundleOrNull(json: String): FhirElementDatabase? {
is Bundle -> {
resource.entry.associateBy { it.id }.toImmutableMap()
}
else -> {
persistentMapOf(resource.id to resource)
}
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -22,6 +22,5 @@ package com.vitorpamplona.amethyst.model
// Re-export from commons for backwards compatibility
typealias Note = com.vitorpamplona.amethyst.commons.model.Note
typealias NotesGatherer = com.vitorpamplona.amethyst.commons.model.NotesGatherer
typealias AddressableNote = com.vitorpamplona.amethyst.commons.model.AddressableNote
typealias NoteState = com.vitorpamplona.amethyst.commons.model.NoteState
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -54,9 +54,7 @@ fun parseThemeType(code: Int?): ThemeType =
ThemeType.SYSTEM.screenCode -> ThemeType.SYSTEM
ThemeType.LIGHT.screenCode -> ThemeType.LIGHT
ThemeType.DARK.screenCode -> ThemeType.DARK
else -> {
ThemeType.SYSTEM
}
else -> ThemeType.SYSTEM
}
enum class ConnectivityType(
@@ -91,9 +89,7 @@ fun parseConnectivityType(code: Boolean?): ConnectivityType =
ConnectivityType.ALWAYS.prefCode -> ConnectivityType.ALWAYS
ConnectivityType.WIFI_ONLY.prefCode -> ConnectivityType.WIFI_ONLY
ConnectivityType.NEVER.prefCode -> ConnectivityType.NEVER
else -> {
ConnectivityType.ALWAYS
}
else -> ConnectivityType.ALWAYS
}
fun parseConnectivityType(screenCode: Int): ConnectivityType =
@@ -101,9 +97,7 @@ fun parseConnectivityType(screenCode: Int): ConnectivityType =
ConnectivityType.ALWAYS.screenCode -> ConnectivityType.ALWAYS
ConnectivityType.WIFI_ONLY.screenCode -> ConnectivityType.WIFI_ONLY
ConnectivityType.NEVER.screenCode -> ConnectivityType.NEVER
else -> {
ConnectivityType.ALWAYS
}
else -> ConnectivityType.ALWAYS
}
fun parseFeatureSetType(screenCode: Int): FeatureSetType =
@@ -111,18 +105,14 @@ fun parseFeatureSetType(screenCode: Int): FeatureSetType =
FeatureSetType.COMPLETE.screenCode -> FeatureSetType.COMPLETE
FeatureSetType.SIMPLIFIED.screenCode -> FeatureSetType.SIMPLIFIED
FeatureSetType.PERFORMANCE.screenCode -> FeatureSetType.PERFORMANCE
else -> {
FeatureSetType.COMPLETE
}
else -> FeatureSetType.COMPLETE
}
fun parseGalleryType(screenCode: Int): ProfileGalleryType =
when (screenCode) {
ProfileGalleryType.CLASSIC.screenCode -> ProfileGalleryType.CLASSIC
ProfileGalleryType.MODERN.screenCode -> ProfileGalleryType.MODERN
else -> {
ProfileGalleryType.CLASSIC
}
else -> ProfileGalleryType.CLASSIC
}
enum class BooleanType(
@@ -138,18 +128,14 @@ fun parseBooleanType(code: Boolean?): BooleanType =
when (code) {
BooleanType.ALWAYS.prefCode -> BooleanType.ALWAYS
BooleanType.NEVER.prefCode -> BooleanType.NEVER
else -> {
BooleanType.ALWAYS
}
else -> BooleanType.ALWAYS
}
fun parseBooleanType(screenCode: Int): BooleanType =
when (screenCode) {
BooleanType.ALWAYS.screenCode -> BooleanType.ALWAYS
BooleanType.NEVER.screenCode -> BooleanType.NEVER
else -> {
BooleanType.ALWAYS
}
else -> BooleanType.ALWAYS
}
enum class WarningType(
@@ -167,9 +153,7 @@ fun parseWarningType(screenCode: Int): WarningType =
WarningType.WARN.screenCode -> WarningType.WARN
WarningType.SHOW.screenCode -> WarningType.SHOW
WarningType.HIDE.screenCode -> WarningType.HIDE
else -> {
WarningType.WARN
}
else -> WarningType.WARN
}
fun parseWarningType(code: Boolean?): WarningType =
@@ -177,7 +161,5 @@ fun parseWarningType(code: Boolean?): WarningType =
WarningType.WARN.prefCode -> WarningType.WARN
WarningType.HIDE.prefCode -> WarningType.HIDE
WarningType.SHOW.prefCode -> WarningType.SHOW
else -> {
WarningType.WARN
}
else -> WarningType.WARN
}
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -22,5 +22,3 @@ package com.vitorpamplona.amethyst.model
// Re-export from commons for backwards compatibility
typealias User = com.vitorpamplona.amethyst.commons.model.User
typealias RelayInfo = com.vitorpamplona.amethyst.commons.model.RelayInfo
typealias UserState = com.vitorpamplona.amethyst.commons.model.UserState
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -68,13 +68,17 @@ class AccountCacheState(
NostrSignerInternal(accountSettings.keyPair)
} else {
when (val packageName = accountSettings.externalSignerPackageName) {
null -> NostrSignerInternal(accountSettings.keyPair)
else ->
null -> {
NostrSignerInternal(accountSettings.keyPair)
}
else -> {
NostrSignerExternal(
pubKey = accountSettings.keyPair.pubKey.toHexKey(),
packageName = packageName,
contentResolver = contentResolverFn(),
)
}
}
},
accountSettings = accountSettings,
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.model.nip01UserMetadata
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.utils.Log
@@ -30,7 +29,6 @@ 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(
@@ -42,11 +40,16 @@ class UserMetadataState(
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
val user = cache.getOrCreateUser(signer.pubKey)
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
val note = cache.getOrCreateAddressableNote(getMetadataEventAddress())
fun getMetadataEventAddress() = MetadataEvent.createAddress(signer.pubKey)
// fun getEphemeralChatListAddress() = cache.getOrCreateUser(signer.pubKey)
fun getUserMetadataFlow(): StateFlow<UserState> = user.flow().metadata.stateFlow
fun getUserMetadataFlow() = note.flow().metadata.stateFlow
fun getUserMetadataEvent(): MetadataEvent? = user.latestMetadata
fun getUserMetadataEvent(): MetadataEvent? = note.event as? MetadataEvent
suspend fun sendNewUserMetadata(
name: String? = null,
@@ -116,8 +119,10 @@ class UserMetadataState(
scope.launch(Dispatchers.IO) {
Log.d("AccountRegisterObservers", "Kind 0 Collector Start")
getUserMetadataFlow().collect {
Log.d("AccountRegisterObservers", "Updating Kind 0 ${it.user.toBestDisplayName()}")
settings.updateUserMetadata(it.user.latestMetadata)
Log.d("AccountRegisterObservers", "Updating Kind 0 ${user.toBestDisplayName()}")
(it.note.event as? MetadataEvent)?.let {
settings.updateUserMetadata(it)
}
}
}
}
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -21,10 +21,10 @@
package com.vitorpamplona.amethyst.model.nip02FollowLists
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.NoteState
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.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
@@ -52,14 +52,19 @@ class Kind3FollowListState(
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
val user = cache.getOrCreateUser(signer.pubKey)
fun getFollowListFlow(): StateFlow<UserState> = user.flow().follows.stateFlow
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
val note = cache.getOrCreateAddressableNote(getFollowListAddress())
fun getFollowListEvent(): ContactListEvent? = user.latestContactList
fun getFollowListAddress() = ContactListEvent.createAddress(signer.pubKey)
fun getFollowListFlow(): StateFlow<NoteState> = note.flow().metadata.stateFlow
fun getFollowListEvent(): ContactListEvent? = note.event as? ContactListEvent
@OptIn(ExperimentalCoroutinesApi::class)
private val innerFlow: Flow<Kind3Follows> =
getFollowListFlow().transformLatest {
emit(buildKind3Follows(it.user.latestContactList ?: settings.backupContactList))
emit(buildKind3Follows(it.note.event as? ContactListEvent ?: settings.backupContactList))
}
val flow =
@@ -149,7 +154,9 @@ class Kind3FollowListState(
Log.d("AccountRegisterObservers", "Kind 3 Collector Start")
getFollowListFlow().collect {
Log.d("AccountRegisterObservers", "Updating Kind 3 ${signer.pubKey}")
settings.updateContactListTo(it.user.latestContactList)
(it.note.event as? ContactListEvent)?.let {
settings.updateContactListTo(it)
}
}
}
}
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -74,7 +74,10 @@ class Nip11CachedRetriever(
val doc = relayInformationDocumentCache.get(relay)
if (doc != null) {
when (doc) {
is RetrieveResult.Success -> onInfo(doc.data)
is RetrieveResult.Success -> {
onInfo(doc.data)
}
is RetrieveResult.Loading -> {
if (doc.isValid()) {
// just wait.
@@ -82,6 +85,7 @@ class Nip11CachedRetriever(
retrieve(relay, onInfo, onError)
}
}
is RetrieveResult.Error -> {
if (doc.isValid()) {
onError(relay, doc.error, null)
@@ -89,7 +93,10 @@ class Nip11CachedRetriever(
retrieve(relay, onInfo, onError)
}
}
is RetrieveResult.Empty -> retrieve(relay, onInfo, onError)
is RetrieveResult.Empty -> {
retrieve(relay, onInfo, onError)
}
}
} else {
retrieve(relay, onInfo, onError)
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -21,11 +21,11 @@
package com.vitorpamplona.amethyst.model.nip47WalletConnect
import com.vitorpamplona.amethyst.commons.model.INwcSignerState
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.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentQueryState
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.relay.normalizer.NormalizedRelayUrl
@@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.Response
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
@@ -59,14 +60,14 @@ import kotlinx.coroutines.launch
* @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
* @property nip47Setup the NIP-47 configuration
*/
class NwcSignerState(
val signer: NostrSigner,
val nwcFilterAssembler: NWCPaymentFilterAssembler,
val cache: LocalCache,
val scope: CoroutineScope,
val settings: AccountSettings,
val nip47Setup: MutableStateFlow<Nip47WalletConnect.Nip47URINorm?>,
) : INwcSignerState {
/**
* Derives a NIP-47 signer from the zap payment request in settings.
@@ -74,14 +75,14 @@ class NwcSignerState(
* Flows updates whenever settings change.
*/
val nip47Signer =
settings.zapPaymentRequest
nip47Setup
.map {
buildSigner(it) ?: signer
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
buildSigner(settings.zapPaymentRequest.value) ?: signer,
buildSigner(nip47Setup.value) ?: signer,
)
/**
@@ -111,30 +112,30 @@ class NwcSignerState(
NostrSignerInternal(KeyPair(it))
}
fun hasWalletConnectSetup(): Boolean = settings.zapPaymentRequest.value != null
fun hasWalletConnectSetup(): Boolean = nip47Setup.value != null
override fun isNIP47Author(pubkeyHex: String?): Boolean = nip47Signer.value.pubKey == pubkeyHex
override fun isNIP47Author(pubkey: HexKey?): Boolean = nip47Signer.value.pubKey == pubkey
/**
* Decrypts a NIP-47 payment request using the current signer.
*
* @param nwcRequest the NIP-47 payment request event to decrypt
* @param event the NIP-47 payment request event to decrypt
* @return the decrypted request or null if not set up or decryption fails
*/
override suspend fun decryptRequest(nwcRequest: LnZapPaymentRequestEvent): Request? {
override suspend fun decryptRequest(event: LnZapPaymentRequestEvent): Request? {
if (!hasWalletConnectSetup()) return null
return zapPaymentRequestDecryptionCache.value.decryptRequest(nwcRequest)
return zapPaymentRequestDecryptionCache.value.decryptRequest(event)
}
/**
* Decrypts a NIP-47 payment response using the current signer.
*
* @param nwsResponse the NIP-47 payment response event to decrypt
* @param event the NIP-47 payment response event to decrypt
* @return the decrypted response or null if not set up or decryption fails
*/
override suspend fun decryptResponse(nwsResponse: LnZapPaymentResponseEvent): Response? {
override suspend fun decryptResponse(event: LnZapPaymentResponseEvent): Response? {
if (!hasWalletConnectSetup()) return null
return zapPaymentResponseDecryptionCache.value.decryptResponse(nwsResponse)
return zapPaymentResponseDecryptionCache.value.decryptResponse(event)
}
/**
@@ -152,8 +153,7 @@ class NwcSignerState(
zappedNote: Note?,
onResponse: (Response?) -> Unit,
): Pair<LnZapPaymentRequestEvent, NormalizedRelayUrl> {
val walletService = settings.zapPaymentRequest.value
if (walletService == null) throw IllegalArgumentException("No NIP47 setup")
val walletService = nip47Setup.value ?: throw IllegalArgumentException("No NIP47 setup")
val event = LnZapPaymentRequestEvent.create(bolt11, walletService.pubKeyHex, nip47Signer.value)
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -81,7 +81,7 @@ class BlockPeopleListState(
)
} else {
PeopleListEvent.create(
name = PeopleListEvent.BLOCK_LIST_D_TAG,
title = PeopleListEvent.BLOCK_LIST_D_TAG,
person = UserTag(pubkeyHex),
isPrivate = true,
signer = signer,
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -87,7 +87,7 @@ class LabeledBookmarkListsState(
suspend fun LabeledBookmarkListEvent.toLabeledBookmarkList() =
LabeledBookmarkList(
identifier = dTag(),
title = nameOrTitle() ?: dTag(),
title = titleOrName() ?: dTag(),
description = description(),
image = image(),
privateBookmarks = privateBookmarks(signer)?.toSet() ?: emptySet(),
@@ -160,7 +160,7 @@ class LabeledBookmarkListsState(
) {
val newList =
LabeledBookmarkListEvent.create(
name = listName,
title = listName,
description = listDescription,
image = listImage,
publicBookmarks = if (!isBookmarkPrivate && firstBookmark != null) listOf(firstBookmark) else emptyList(),
@@ -200,7 +200,7 @@ class LabeledBookmarkListsState(
val renamedList =
LabeledBookmarkListEvent.modifyName(
earlierVersion = listEvent,
newName = newName,
newTitle = newName,
signer = account.signer,
)
account.sendMyPublicAndPrivateOutbox(renamedList)
@@ -229,7 +229,7 @@ class LabeledBookmarkListsState(
) {
val clonedList =
LabeledBookmarkListEvent.create(
name = customCloneName ?: currentBookmarkList.title,
title = customCloneName ?: currentBookmarkList.title,
description = customCloneDescription ?: currentBookmarkList.description,
publicBookmarks = currentBookmarkList.publicBookmarks.toList(),
privateBookmarks = currentBookmarkList.privateBookmarks.toList(),
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -118,7 +118,7 @@ class PeopleListsState(
suspend fun PeopleListEvent.toUI() =
PeopleList(
identifierTag = this.dTag(),
title = this.nameOrTitle() ?: this.dTag(),
title = this.titleOrName() ?: this.dTag(),
description = this.description(),
image = this.image(),
privateMembers = cache.load(decryptionCache.privateUserIdSet(this)),
@@ -197,7 +197,7 @@ class PeopleListsState(
val newListTemplate =
PeopleListEvent.build(
dTag = dTag,
name = listName,
title = listName,
publicMembers = if (!isPrivate && member != null) listOf(member.toUserTag()) else emptyList(),
privateMembers = if (isPrivate && member != null) listOf(member.toUserTag()) else emptyList(),
signer = account.signer,
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of

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